diff options
Diffstat (limited to 'apps/web/src/project/transfer/__tests__/container.test.ts')
| -rw-r--r-- | apps/web/src/project/transfer/__tests__/container.test.ts | 204 |
1 files changed, 204 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, + ); + }); +}); |
