diff options
Diffstat (limited to 'apps/web/src/project/transfer/__tests__')
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, + ]); + } + }); +}); |
