summaryrefslogtreecommitdiff
path: root/apps/web/src/project/transfer/__tests__/container.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/project/transfer/__tests__/container.test.ts')
-rw-r--r--apps/web/src/project/transfer/__tests__/container.test.ts204
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,
+ );
+ });
+});