memos/web/tests/upload-media-metadata.test.ts
boojack b3e67399b8 feat(attachment): upload files in resumable chunks
Add UploadAttachment, a unary RPC that accepts a file in bounded chunks
and streams it to a temp file instead of holding the whole blob in one
request body and in memory. A call carrying a spec starts an upload and
returns an opaque upload_id; later calls carry that id with
write_offset,
data, and finish_write, following the Cloud Storage WriteObject shape.
Retrying the last chunk after a lost response is idempotent, uploads are
bound to their owner, and they expire after 30 minutes of inactivity.

CreateAttachment and the chunked finalize now share one processing
pipeline for motion-photo detection, EXIF stripping, and storage. The
motion-photo detector works over an io.ReaderAt so large JPEGs are never
loaded whole, and the request body cap is a single per-procedure lookup.

The web editor uploads through the new RPC in 2 MiB chunks with bounded
concurrency under the server's per-user limit.
2026-09-08 21:07:06 +08:00

100 lines
4 KiB
TypeScript

import { create } from "@bufbuild/protobuf";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { LocalFile } from "@/components/MemoEditor/types/attachment";
import { MediaMetadataSchema } from "@/types/proto/api/v1/attachment_service_pb";
const mocks = vi.hoisted(() => ({
uploadAttachment: vi.fn(),
extractMetadata: vi.fn(),
}));
// The ingest helper lives beside useFileUpload, whose module graph reaches
// AuthContext and the query hooks; stub every client they name-import.
vi.mock("@/connect", () => ({
attachmentServiceClient: { uploadAttachment: mocks.uploadAttachment },
authServiceClient: {},
userServiceClient: {},
memoViewServiceClient: {},
refreshAccessToken: vi.fn(),
}));
vi.mock("@/components/MemoEditor/services/mediaMetadataService", () => ({
mediaMetadataService: { extract: mocks.extractMetadata },
}));
import { toLocalFiles } from "@/components/MemoEditor/hooks/useFileUpload";
import { uploadService } from "@/components/MemoEditor/services/uploadService";
const createBlobUrl = (file: File) => `blob:${file.name}`;
const localImage = (mediaMetadata?: LocalFile["mediaMetadata"]): LocalFile => ({
file: new File(["image"], "photo.png", { type: "image/png" }),
previewUrl: "blob:photo",
origin: "upload",
mediaMetadata,
});
describe("media metadata at file ingest", () => {
it("does not extract metadata when the setting is disabled", () => {
const file = new File(["image"], "photo.png", { type: "image/png" });
const [localFile] = toLocalFiles([file], { createBlobUrl, saveMediaMetadata: false });
expect(mocks.extractMetadata).not.toHaveBeenCalled();
expect(localFile.mediaMetadata).toBeUndefined();
});
it("starts extraction per file when the setting is enabled", async () => {
const metadata = create(MediaMetadataSchema, { width: 1200, height: 800 });
mocks.extractMetadata.mockResolvedValue(metadata);
const file = new File(["image"], "photo.png", { type: "image/png" });
const [localFile] = toLocalFiles([file], { createBlobUrl, saveMediaMetadata: true });
expect(mocks.extractMetadata).toHaveBeenCalledWith(file);
await expect(localFile.mediaMetadata).resolves.toEqual(metadata);
});
});
describe("uploadService media metadata", () => {
beforeEach(() => {
mocks.uploadAttachment.mockImplementation(async ({ data }) => ({
uploadId: "test-id",
maxChunkSize: 2 * 1024 * 1024,
committedSize: BigInt(data?.length ?? 0),
attachment: data ? { name: "attachments/test" } : undefined,
}));
});
it("submits no metadata for files ingested without any", async () => {
await uploadService.uploadFiles([localImage()]);
expect(mocks.uploadAttachment).toHaveBeenCalledTimes(2);
expect(mocks.uploadAttachment.mock.calls[0][0].upload.value.attachment.mediaMetadata).toBeUndefined();
});
it("submits the metadata extracted at ingest", async () => {
const metadata = create(MediaMetadataSchema, { width: 1200, height: 800 });
await uploadService.uploadFiles([localImage(Promise.resolve(metadata))]);
expect(mocks.uploadAttachment.mock.calls[0][0].upload.value.attachment.mediaMetadata).toEqual(metadata);
});
it("continues without metadata when extraction produced no usable values", async () => {
await uploadService.uploadFiles([localImage(Promise.resolve(undefined))]);
expect(mocks.uploadAttachment).toHaveBeenCalledTimes(2);
expect(mocks.uploadAttachment.mock.calls[0][0].upload.value.attachment.mediaMetadata).toBeUndefined();
});
it("submits ingest-time metadata end to end", async () => {
const metadata = create(MediaMetadataSchema, { width: 640, height: 480 });
mocks.extractMetadata.mockResolvedValue(metadata);
const file = new File(["image"], "photo.png", { type: "image/png" });
await uploadService.uploadFiles(toLocalFiles([file], { createBlobUrl, saveMediaMetadata: true }));
expect(mocks.uploadAttachment.mock.calls[0][0].upload.value.attachment.mediaMetadata).toEqual(metadata);
});
});