From 30a36e45686db503fded68e4da5d4c465a06f978 Mon Sep 17 00:00:00 2001 From: cashmere Date: Mon, 27 Jul 2026 01:21:20 +0200 Subject: feat: single-file project import/export (.ocp) fflate zip container: project.json (serialized project incl. schema version) + media files with a manifest. Export gathers all project media (missing blobs warn, never abort) after save-flush; import validates everything before any write, migrates older archives through the storage migration registry, remaps media ids, and creates a new project with rollback on failure. Entry points: projects page import/export, editor header menu, export-project-archive action. 29 tests incl. full round-trip. --- .../src/project/transfer/__tests__/migrate.test.ts | 153 +++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 apps/web/src/project/transfer/__tests__/migrate.test.ts (limited to 'apps/web/src/project/transfer/__tests__/migrate.test.ts') 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> { + 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); + }); +}); -- cgit v1.3.1