diff options
| author | cashmere <cashmere@autistici.org> | 2026-07-27 01:21:20 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-07-27 01:21:20 +0200 |
| commit | 30a36e45686db503fded68e4da5d4c465a06f978 (patch) | |
| tree | 2dbe5e2aaef543155b021f5f4c504fad77b40648 /apps/web/src/project/transfer/__tests__/migrate.test.ts | |
| parent | d55d2583a71ae35fa8d0d1bfbec975e67e66aafa (diff) | |
| download | opencut-30a36e45686db503fded68e4da5d4c465a06f978.tar.gz | |
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.
Diffstat (limited to 'apps/web/src/project/transfer/__tests__/migrate.test.ts')
| -rw-r--r-- | apps/web/src/project/transfer/__tests__/migrate.test.ts | 153 |
1 files changed, 153 insertions, 0 deletions
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); + }); +}); |
