summaryrefslogtreecommitdiff
path: root/apps/web/src/project/transfer/__tests__
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/project/transfer/__tests__')
-rw-r--r--apps/web/src/project/transfer/__tests__/container.test.ts204
-rw-r--r--apps/web/src/project/transfer/__tests__/migrate.test.ts153
-rw-r--r--apps/web/src/project/transfer/__tests__/remap.test.ts166
-rw-r--r--apps/web/src/project/transfer/__tests__/round-trip.test.ts221
4 files changed, 744 insertions, 0 deletions
diff --git a/apps/web/src/project/transfer/__tests__/container.test.ts b/apps/web/src/project/transfer/__tests__/container.test.ts
new file mode 100644
index 00000000..27c3335d
--- /dev/null
+++ b/apps/web/src/project/transfer/__tests__/container.test.ts
@@ -0,0 +1,204 @@
+import { describe, expect, test } from "bun:test";
+import { zipSync, strToU8 } from "fflate";
+import {
+ MEDIA_MANIFEST_ENTRY,
+ PROJECT_JSON_ENTRY,
+ ProjectArchiveError,
+ buildMediaEntryName,
+ buildProjectArchive,
+ parseProjectArchive,
+ sanitizeFileName,
+} from "../container";
+
+const projectJson = JSON.stringify({
+ version: 31,
+ metadata: {
+ id: "project-1",
+ name: "Demo",
+ createdAt: "2024-06-01T10:00:00.000Z",
+ updatedAt: "2024-06-01T14:00:00.000Z",
+ },
+ scenes: [],
+ currentSceneId: "scene-1",
+ settings: { fps: 30 },
+});
+
+function buildMediaInputs() {
+ return [
+ {
+ metadata: {
+ id: "media-1",
+ name: "clip one.mp4",
+ mediaType: "video" as const,
+ fileType: "video/mp4",
+ lastModified: 1_700_000_000_000,
+ width: 1920,
+ height: 1080,
+ duration: 3.5,
+ },
+ data: new Uint8Array([1, 2, 3, 4, 5]),
+ },
+ {
+ metadata: {
+ id: "media-2",
+ name: "picturé/odd:name.png",
+ mediaType: "image" as const,
+ fileType: "image/png",
+ lastModified: 1_700_000_000_001,
+ thumbnailUrl: "data:image/png;base64,abc",
+ },
+ data: new Uint8Array([9, 8, 7]),
+ },
+ ];
+}
+
+describe("sanitizeFileName", () => {
+ test("replaces unsafe characters and keeps the result stable", () => {
+ expect(sanitizeFileName({ name: "my clip.mp4" })).toBe("my_clip.mp4");
+ expect(sanitizeFileName({ name: "a/b\\c:d" })).toBe("a_b_c_d");
+ expect(sanitizeFileName({ name: "picturé.png" })).toBe("pictur_.png");
+ });
+
+ test("falls back for empty or dot-only names", () => {
+ expect(sanitizeFileName({ name: " " })).toBe("file");
+ expect(sanitizeFileName({ name: "..." })).toBe("file");
+ });
+});
+
+describe("buildMediaEntryName", () => {
+ test("prefixes the asset id and sanitizes the name", () => {
+ expect(buildMediaEntryName({ assetId: "abc", name: "my clip.mp4" })).toBe(
+ "media/abc-my_clip.mp4",
+ );
+ });
+});
+
+describe("buildProjectArchive + parseProjectArchive", () => {
+ test("round-trips project document, manifest, and media bytes", () => {
+ const media = buildMediaInputs();
+ const archive = buildProjectArchive({ projectJson, media });
+ const parsed = parseProjectArchive({ archive });
+
+ expect(JSON.parse(JSON.stringify(parsed.project))).toEqual(
+ JSON.parse(projectJson),
+ );
+
+ expect(parsed.media).toHaveLength(2);
+ const [first, second] = parsed.media;
+ expect(first).toMatchObject({
+ id: "media-1",
+ name: "clip one.mp4",
+ mediaType: "video",
+ fileType: "video/mp4",
+ lastModified: 1_700_000_000_000,
+ width: 1920,
+ height: 1080,
+ duration: 3.5,
+ entry: "media/media-1-clip_one.mp4",
+ });
+ expect(second).toMatchObject({
+ id: "media-2",
+ name: "picturé/odd:name.png",
+ mediaType: "image",
+ fileType: "image/png",
+ thumbnailUrl: "data:image/png;base64,abc",
+ });
+
+ expect([...parsed.readMediaData({ entry: first.entry })]).toEqual([
+ 1, 2, 3, 4, 5,
+ ]);
+ expect([...parsed.readMediaData({ entry: second.entry })]).toEqual([9, 8, 7]);
+ });
+
+ test("supports archives without media", () => {
+ const archive = buildProjectArchive({ projectJson, media: [] });
+ const parsed = parseProjectArchive({ archive });
+ expect(parsed.media).toEqual([]);
+ });
+});
+
+describe("parseProjectArchive validation", () => {
+ test("rejects non-zip data", () => {
+ expect(() =>
+ parseProjectArchive({ archive: new Uint8Array([1, 2, 3]) }),
+ ).toThrow(ProjectArchiveError);
+ });
+
+ test("rejects archives without project.json", () => {
+ const archive = zipSync({ "other.txt": strToU8("hello") });
+ expect(() => parseProjectArchive({ archive })).toThrow(/project\.json/);
+ });
+
+ test("rejects invalid project.json", () => {
+ const archive = zipSync({
+ [PROJECT_JSON_ENTRY]: strToU8("not json{"),
+ });
+ expect(() => parseProjectArchive({ archive })).toThrow(/not valid JSON/);
+ });
+
+ test("rejects non-object project.json", () => {
+ const archive = zipSync({ [PROJECT_JSON_ENTRY]: strToU8("[1,2,3]") });
+ expect(() => parseProjectArchive({ archive })).toThrow(/not a project/);
+ });
+
+ test("rejects media entries without a manifest", () => {
+ const archive = zipSync({
+ [PROJECT_JSON_ENTRY]: strToU8(projectJson),
+ "media/media-1-clip.mp4": new Uint8Array([1]),
+ });
+ expect(() => parseProjectArchive({ archive })).toThrow(/manifest/);
+ });
+
+ test("rejects manifest entries whose media file is missing", () => {
+ const archive = zipSync({
+ [PROJECT_JSON_ENTRY]: strToU8(projectJson),
+ [MEDIA_MANIFEST_ENTRY]: strToU8(
+ JSON.stringify({
+ version: 1,
+ media: [
+ {
+ id: "media-1",
+ name: "clip.mp4",
+ entry: "media/media-1-clip.mp4",
+ mediaType: "video",
+ fileType: "video/mp4",
+ lastModified: 1,
+ },
+ ],
+ }),
+ ),
+ });
+ expect(() => parseProjectArchive({ archive })).toThrow(/is missing/);
+ });
+
+ test("rejects manifest entries with paths outside media/", () => {
+ const archive = zipSync({
+ [PROJECT_JSON_ENTRY]: strToU8(projectJson),
+ [MEDIA_MANIFEST_ENTRY]: strToU8(
+ JSON.stringify({
+ version: 1,
+ media: [
+ {
+ id: "media-1",
+ name: "evil",
+ entry: "../evil",
+ mediaType: "video",
+ fileType: "video/mp4",
+ lastModified: 1,
+ },
+ ],
+ }),
+ ),
+ "media/placeholder": new Uint8Array([1]),
+ });
+ expect(() => parseProjectArchive({ archive })).toThrow(/invalid file path/);
+ });
+
+ test("readMediaData throws for unknown entries", () => {
+ const archive = buildProjectArchive({ projectJson, media: [] });
+ const parsed = parseProjectArchive({ archive });
+ expect(() => parsed.readMediaData({ entry: "media/nope" })).toThrow(
+ ProjectArchiveError,
+ );
+ });
+});
diff --git a/apps/web/src/project/transfer/__tests__/migrate.test.ts b/apps/web/src/project/transfer/__tests__/migrate.test.ts
new file mode 100644
index 00000000..af4bb37e
--- /dev/null
+++ b/apps/web/src/project/transfer/__tests__/migrate.test.ts
@@ -0,0 +1,153 @@
+import { describe, expect, test } from "bun:test";
+import {
+ StorageMigration,
+ type StorageMigrationRunArgs,
+} from "@/services/storage/migrations/base";
+import type {
+ MigrationResult,
+ ProjectRecord,
+} from "@/services/storage/migrations/transformers/types";
+import {
+ CURRENT_PROJECT_VERSION,
+ migrations,
+} from "@/services/storage/migrations";
+import { v5Project } from "@/services/storage/migrations/__tests__/fixtures";
+import { getRecordVersion, migrateProjectRecord } from "../migrate";
+
+class StepMigration extends StorageMigration {
+ from: number;
+ to: number;
+ private transform: ({ project }: { project: ProjectRecord }) => ProjectRecord;
+ private shouldSkip: boolean;
+
+ constructor({
+ from,
+ to,
+ transform,
+ shouldSkip = false,
+ }: {
+ from: number;
+ to: number;
+ transform: ({ project }: { project: ProjectRecord }) => ProjectRecord;
+ shouldSkip?: boolean;
+ }) {
+ super();
+ this.from = from;
+ this.to = to;
+ this.transform = transform;
+ this.shouldSkip = shouldSkip;
+ }
+
+ async run({
+ project,
+ }: StorageMigrationRunArgs): Promise<MigrationResult<ProjectRecord>> {
+ if (this.shouldSkip) {
+ return { project, skipped: true, reason: "skip requested" };
+ }
+ return { project: this.transform({ project }), skipped: false };
+ }
+}
+
+function setVersion({
+ project,
+ version,
+}: {
+ project: ProjectRecord;
+ version: number;
+}): ProjectRecord {
+ return { ...project, version };
+}
+
+describe("getRecordVersion", () => {
+ test("uses the explicit version field", () => {
+ expect(getRecordVersion({ project: { version: 7 } })).toBe(7);
+ });
+
+ test("detects v1 projects by their scenes array", () => {
+ expect(getRecordVersion({ project: { scenes: [{ id: "s" }] } })).toBe(1);
+ });
+
+ test("falls back to v0", () => {
+ expect(getRecordVersion({ project: {} })).toBe(0);
+ expect(getRecordVersion({ project: { scenes: [] } })).toBe(0);
+ });
+});
+
+describe("migrateProjectRecord", () => {
+ test("applies migrations in version order regardless of registration order", async () => {
+ const registry = [
+ new StepMigration({
+ from: 1,
+ to: 2,
+ transform: ({ project }) => setVersion({ project, version: 2 }),
+ }),
+ new StepMigration({
+ from: 0,
+ to: 1,
+ transform: ({ project }) => setVersion({ project, version: 1 }),
+ }),
+ ];
+
+ const result = await migrateProjectRecord({
+ project: { version: 0, steps: [] },
+ migrations: registry,
+ });
+
+ expect(result.fromVersion).toBe(0);
+ expect(result.toVersion).toBe(2);
+ expect(result.complete).toBe(true);
+ expect(result.project.version).toBe(2);
+ });
+
+ test("reports incomplete when a migration skips", async () => {
+ const registry = [
+ new StepMigration({
+ from: 0,
+ to: 1,
+ transform: ({ project }) => project,
+ shouldSkip: true,
+ }),
+ new StepMigration({
+ from: 1,
+ to: 2,
+ transform: ({ project }) => setVersion({ project, version: 2 }),
+ }),
+ ];
+
+ const result = await migrateProjectRecord({
+ project: { version: 0 },
+ migrations: registry,
+ });
+
+ expect(result.toVersion).toBe(0);
+ expect(result.complete).toBe(false);
+ });
+
+ test("is a no-op for current-version documents", async () => {
+ const registry = [
+ new StepMigration({
+ from: 0,
+ to: 1,
+ transform: ({ project }) => setVersion({ project, version: 1 }),
+ }),
+ ];
+
+ const project = { version: 1, marker: "untouched" };
+ const result = await migrateProjectRecord({ project, migrations: registry });
+
+ expect(result.complete).toBe(true);
+ expect(result.project).toBe(project);
+ });
+
+ test("migrates a real v5 fixture to the current version with the app registry", async () => {
+ const result = await migrateProjectRecord({
+ project: structuredClone(v5Project),
+ migrations,
+ });
+
+ expect(result.fromVersion).toBe(5);
+ expect(result.complete).toBe(true);
+ expect(result.toVersion).toBe(CURRENT_PROJECT_VERSION);
+ expect(result.project.version).toBe(CURRENT_PROJECT_VERSION);
+ });
+});
diff --git a/apps/web/src/project/transfer/__tests__/remap.test.ts b/apps/web/src/project/transfer/__tests__/remap.test.ts
new file mode 100644
index 00000000..356a91ab
--- /dev/null
+++ b/apps/web/src/project/transfer/__tests__/remap.test.ts
@@ -0,0 +1,166 @@
+import { describe, expect, test } from "bun:test";
+import type { ProjectRecord } from "@/services/storage/migrations/transformers/types";
+import {
+ collectReferencedMediaIds,
+ remapProjectMediaIds,
+ resolveImportedProjectName,
+} from "../remap";
+
+function buildProject(): ProjectRecord {
+ return {
+ version: 31,
+ metadata: { id: "project-1", name: "Demo" },
+ currentSceneId: "scene-1",
+ scenes: [
+ {
+ id: "scene-1",
+ tracks: {
+ main: {
+ id: "track-main",
+ elements: [
+ { id: "el-video", type: "video", mediaId: "media-video" },
+ { id: "el-image", type: "image", mediaId: "media-image" },
+ ],
+ },
+ overlay: [
+ {
+ id: "track-text",
+ elements: [{ id: "el-text", type: "text", content: "hello" }],
+ },
+ ],
+ audio: [
+ {
+ id: "track-audio",
+ elements: [
+ {
+ id: "el-upload",
+ type: "audio",
+ sourceType: "upload",
+ mediaId: "media-audio",
+ },
+ {
+ id: "el-library",
+ type: "audio",
+ sourceType: "library",
+ sourceUrl: "https://example.com/sound.mp3",
+ },
+ {
+ id: "el-shared",
+ type: "audio",
+ sourceType: "upload",
+ mediaId: "media-video",
+ },
+ ],
+ },
+ ],
+ },
+ },
+ {
+ id: "scene-2",
+ tracks: {
+ main: {
+ id: "track-main-2",
+ elements: [
+ { id: "el-video-2", type: "video", mediaId: "media-video-2" },
+ ],
+ },
+ overlay: [],
+ audio: [],
+ },
+ },
+ ],
+ };
+}
+
+describe("collectReferencedMediaIds", () => {
+ test("collects media ids across scenes and track groups, deduplicated", () => {
+ const ids = collectReferencedMediaIds({ project: buildProject() });
+ expect(ids.sort()).toEqual([
+ "media-audio",
+ "media-image",
+ "media-video",
+ "media-video-2",
+ ]);
+ });
+
+ test("tolerates malformed shapes", () => {
+ expect(collectReferencedMediaIds({ project: null })).toEqual([]);
+ expect(collectReferencedMediaIds({ project: { scenes: "nope" } })).toEqual(
+ [],
+ );
+ expect(
+ collectReferencedMediaIds({
+ project: { scenes: [{ tracks: { main: { elements: "nope" } } }] },
+ }),
+ ).toEqual([]);
+ });
+});
+
+describe("remapProjectMediaIds", () => {
+ test("replaces mapped ids only, across every track group", () => {
+ const project = buildProject();
+ const idMap = new Map([
+ ["media-video", "new-video"],
+ ["media-image", "new-image"],
+ ["media-audio", "new-audio"],
+ ]);
+
+ const remapped = remapProjectMediaIds({ project, idMap });
+ const collected = collectReferencedMediaIds({ project: remapped });
+ expect(collected.sort()).toEqual([
+ "media-video-2",
+ "new-audio",
+ "new-image",
+ "new-video",
+ ]);
+
+ // The shared media id is remapped in every referencing element.
+ const scenes = remapped.scenes;
+ expect(JSON.stringify(scenes)).not.toContain("media-video\"");
+ expect(JSON.stringify(scenes)).toContain("new-video");
+ });
+
+ test("does not mutate the input document", () => {
+ const project = buildProject();
+ const before = JSON.stringify(project);
+ remapProjectMediaIds({
+ project,
+ idMap: new Map([["media-video", "new-video"]]),
+ });
+ expect(JSON.stringify(project)).toBe(before);
+ });
+
+ test("returns the input unchanged when the map is empty", () => {
+ const project = buildProject();
+ expect(remapProjectMediaIds({ project, idMap: new Map() })).toBe(project);
+ });
+
+ test("is fully reversible with the inverse map", () => {
+ const project = buildProject();
+ const idMap = new Map([
+ ["media-video", "new-video"],
+ ["media-image", "new-image"],
+ ["media-audio", "new-audio"],
+ ["media-video-2", "new-video-2"],
+ ]);
+ const inverse = new Map([...idMap].map(([from, to]) => [to, from]));
+
+ const remapped = remapProjectMediaIds({ project, idMap });
+ const restored = remapProjectMediaIds({ project: remapped, idMap: inverse });
+ expect(restored).toEqual(project);
+ });
+});
+
+describe("resolveImportedProjectName", () => {
+ test("keeps the name when there is no collision", () => {
+ expect(
+ resolveImportedProjectName({ name: "Demo", existingNames: ["Other"] }),
+ ).toBe("Demo");
+ });
+
+ test("suffixes the name when it collides", () => {
+ expect(
+ resolveImportedProjectName({ name: "Demo", existingNames: ["Demo"] }),
+ ).toBe("Demo (imported)");
+ });
+});
diff --git a/apps/web/src/project/transfer/__tests__/round-trip.test.ts b/apps/web/src/project/transfer/__tests__/round-trip.test.ts
new file mode 100644
index 00000000..4eb2e8bf
--- /dev/null
+++ b/apps/web/src/project/transfer/__tests__/round-trip.test.ts
@@ -0,0 +1,221 @@
+import { describe, expect, test } from "bun:test";
+import {
+ CURRENT_PROJECT_VERSION,
+ migrations,
+} from "@/services/storage/migrations";
+import type { ProjectRecord } from "@/services/storage/migrations/transformers/types";
+import { buildProjectArchive, parseProjectArchive } from "../container";
+import { migrateProjectRecord } from "../migrate";
+import {
+ collectReferencedMediaIds,
+ remapProjectMediaIds,
+} from "../remap";
+
+/**
+ * The transfer contract, proven at the pure-function level:
+ * export -> import keeps the project document deep-equal (modulo the media
+ * id remap) and every media file byte-identical.
+ */
+
+function buildCurrentProject(): ProjectRecord {
+ return {
+ version: CURRENT_PROJECT_VERSION,
+ metadata: {
+ id: "project-1",
+ name: "Round Trip",
+ thumbnail: "data:image/png;base64,thumb",
+ duration: 240_000,
+ createdAt: "2024-06-01T10:00:00.000Z",
+ updatedAt: "2024-06-01T14:00:00.000Z",
+ },
+ currentSceneId: "scene-1",
+ settings: {
+ fps: 30,
+ canvasSize: { width: 1920, height: 1080 },
+ canvasSizeMode: "preset",
+ lastCustomCanvasSize: null,
+ originalCanvasSize: null,
+ background: { type: "color", color: "#000000" },
+ },
+ timelineViewState: { zoomLevel: 1, scrollLeft: 0, playheadTime: 0 },
+ scenes: [
+ {
+ id: "scene-1",
+ name: "Main scene",
+ isMain: true,
+ bookmarks: [{ time: 120_000, note: "mid", color: "#fff" }],
+ createdAt: "2024-06-01T10:00:00.000Z",
+ updatedAt: "2024-06-01T14:00:00.000Z",
+ tracks: {
+ main: {
+ id: "track-main",
+ type: "video",
+ name: "Main",
+ muted: false,
+ hidden: false,
+ elements: [
+ {
+ id: "el-video",
+ type: "video",
+ mediaId: "media-video",
+ name: "clip.mp4",
+ duration: 240_000,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ params: { opacity: 1 },
+ },
+ {
+ id: "el-image",
+ type: "image",
+ mediaId: "media-image",
+ name: "still.png",
+ duration: 120_000,
+ startTime: 240_000,
+ trimStart: 0,
+ trimEnd: 0,
+ params: {},
+ },
+ ],
+ },
+ overlay: [
+ {
+ id: "track-text",
+ type: "text",
+ name: "Text",
+ hidden: false,
+ elements: [
+ {
+ id: "el-text",
+ type: "text",
+ name: "Title",
+ duration: 60_000,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ params: { content: "Hello" },
+ },
+ ],
+ },
+ ],
+ audio: [
+ {
+ id: "track-audio",
+ type: "audio",
+ name: "Audio",
+ muted: false,
+ elements: [
+ {
+ id: "el-audio",
+ type: "audio",
+ sourceType: "upload",
+ mediaId: "media-audio",
+ name: "song.mp3",
+ duration: 240_000,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ params: { volume: 0.8 },
+ },
+ ],
+ },
+ ],
+ },
+ },
+ ],
+ };
+}
+
+function buildMedia() {
+ return [
+ {
+ metadata: {
+ id: "media-video",
+ name: "clip.mp4",
+ mediaType: "video" as const,
+ fileType: "video/mp4",
+ lastModified: 1_700_000_000_000,
+ width: 1920,
+ height: 1080,
+ duration: 2,
+ },
+ data: new Uint8Array([0, 1, 2, 3, 250, 251, 252, 253, 254, 255]),
+ },
+ {
+ metadata: {
+ id: "media-image",
+ name: "still.png",
+ mediaType: "image" as const,
+ fileType: "image/png",
+ lastModified: 1_700_000_000_001,
+ width: 800,
+ height: 600,
+ thumbnailUrl: "data:image/png;base64,still",
+ },
+ data: new Uint8Array([137, 80, 78, 71]),
+ },
+ {
+ metadata: {
+ id: "media-audio",
+ name: "song.mp3",
+ mediaType: "audio" as const,
+ fileType: "audio/mpeg",
+ lastModified: 1_700_000_000_002,
+ duration: 2,
+ },
+ data: new Uint8Array([255, 251, 144, 0]),
+ },
+ ];
+}
+
+describe("project transfer round trip", () => {
+ test("export -> import keeps the document deep-equal and media byte-identical", async () => {
+ const project = buildCurrentProject();
+ const media = buildMedia();
+
+ // "Export": pack the serialized document plus original media bytes.
+ const archive = buildProjectArchive({
+ projectJson: JSON.stringify(project),
+ media,
+ });
+
+ // "Import": parse, run the same migration pipeline, remap media ids.
+ const parsed = parseProjectArchive({ archive });
+ const migrated = await migrateProjectRecord({
+ project: parsed.project,
+ migrations,
+ });
+ expect(migrated.complete).toBe(true);
+
+ const referenced = collectReferencedMediaIds({ project });
+ expect(referenced.sort()).toEqual(
+ media.map(({ metadata }) => metadata.id).sort(),
+ );
+
+ const idMap = new Map(
+ parsed.media.map((entry) => [entry.id, `imported-${entry.id}`]),
+ );
+ const imported = remapProjectMediaIds({ project: migrated.project, idMap });
+
+ // Every media reference points at a fresh id, nothing else changed:
+ // remapping back with the inverse map restores the original document.
+ expect(collectReferencedMediaIds({ project: imported }).sort()).toEqual(
+ [...idMap.values()].sort(),
+ );
+ const restored = remapProjectMediaIds({
+ project: imported,
+ idMap: new Map([...idMap].map(([from, to]) => [to, from])),
+ });
+ expect(restored).toEqual(project);
+
+ // Media comes back byte-identical with its full metadata.
+ for (const { metadata, data } of media) {
+ const entry = parsed.media.find(({ id }) => id === metadata.id);
+ expect(entry).toBeDefined();
+ expect(entry).toMatchObject(metadata);
+ expect([...parsed.readMediaData({ entry: entry?.entry ?? "" })]).toEqual([
+ ...data,
+ ]);
+ }
+ });
+});