import { test, type TestContext } from "node:test"; import assert from "node:assert/strict"; import { createServer } from "node:http"; import { spawn } from "node:child_process"; import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { resolve, sep } from "node:path"; const script = resolve("scripts/reprocess.mjs"); const id = "00000000-0000-4000-8000-000000000001"; const baseDataset = { id, source: "fabric", state: "review", revision_id: null, rejection_reason: null, map_path: "old-map.webp", cover_path: "old-cover.webp", panorama_count: 12, }; const baseJob = { id: "00000000-0000-4000-8000-000000000002", state: "complete", updated_at: "2026-10-04T12:00:00Z", attempts: 3, progress: 100, error: null, }; async function run( t: TestContext, options: { published?: boolean; active?: boolean; jobConflict?: boolean; contribution?: boolean; mixed?: boolean; } = {}, ) { const dataset = { ...baseDataset, ...(options.published ? { state: "published", revision_id: id } : {}), ...(options.contribution ? { parent_dataset_id: id } : {}), }; const job = { ...baseJob, ...(options.active ? { state: "running" } : {}) }; const input = { validated: true, manifest: { viewpoints: [{ id: "p0", ...(options.mixed ? { assetRoot: id } : {}) }], }, }; const patches: { table: string; body: Record }[] = []; const server = createServer(async (req, res) => { const url = new URL(req.url!, "http://localhost"); const table = url.pathname.split("/").at(-1)!; const row = table === "datasets" ? dataset : table === "upload_jobs" ? job : input; res.setHeader("Content-Type", "application/json"); if (req.method === "GET") { res.end(JSON.stringify(row)); return; } let raw = ""; for await (const chunk of req) raw += chunk; const body = JSON.parse(raw); patches.push({ table, body }); if (options.jobConflict && table === "upload_jobs") { res.end("[]"); return; } Object.assign(row, body); res.end( url.searchParams.has("select") ? JSON.stringify([{ id: table === "datasets" ? id : job.id }]) : "null", ); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const parent = resolve(".cache"); await mkdir(parent, { recursive: true }); const directory = await mkdtemp(`${parent}/reprocess-tests-`); t.after(async () => { await new Promise((resolve) => server.close(() => resolve())); assert.ok(resolve(directory).startsWith(parent + sep)); await rm(directory, { recursive: true, force: true }); }); const address = server.address(); assert.ok(address && typeof address !== "string"); const result = await new Promise<{ code: number | null; output: string }>( (resolve, reject) => { const child = spawn(process.execPath, [script, id], { cwd: directory, windowsHide: true, env: { ...process.env, NEXT_PUBLIC_SUPABASE_URL: `http://127.0.0.1:${address.port}`, SUPABASE_SECRET_KEY: "test-only-secret", }, stdio: ["ignore", "pipe", "pipe"], }); let output = ""; child.stdout.on("data", (data) => (output += data)); child.stderr.on("data", (data) => (output += data)); child.on("error", reject); child.on("exit", (code) => resolve({ code, output })); }, ); return { ...result, dataset, job, input, patches }; } test("reprocessing keeps the upload and resets unpublished preview and exhausted job metadata", async (t) => { const result = await run(t); assert.equal(result.code, 0, result.output); assert.equal(result.dataset.state, "processing"); assert.equal(result.dataset.map_path, null); assert.equal(result.dataset.cover_path, null); assert.equal(result.dataset.panorama_count, 0); assert.equal(result.input.validated, false); assert.equal(result.job.state, "queued"); assert.equal(result.job.attempts, 0); assert.equal(result.job.progress, 0); assert.equal(result.job.error, null); assert.ok( result.patches.every(({ table }) => ["datasets", "upload_jobs", "dataset_inputs"].includes(table), ), ); }); test("reprocessing cannot mutate published revisions", async (t) => { const result = await run(t, { published: true }); assert.notEqual(result.code, 0); assert.match(result.output, /Published revisions cannot be changed/); assert.deepEqual(result.patches, []); }); test("accepted contribution media cannot be overwritten through reprocessing", async (t) => { for (const options of [{ contribution: true }, { mixed: true }]) { const result = await run(t, options); assert.notEqual(result.code, 0); assert.match( result.output, /Contributions cannot be overwritten|accepted contributions/, ); assert.deepEqual(result.patches, []); } }); test("reprocessing cannot take over an active worker job", async (t) => { const result = await run(t, { active: true }); assert.notEqual(result.code, 0); assert.match(result.output, /already queued or running/); assert.deepEqual(result.patches, []); }); test("a queue conflict restores the previous draft metadata and validation status", async (t) => { const result = await run(t, { jobConflict: true }); assert.notEqual(result.code, 0); assert.match(result.output, /Job state changed/); assert.equal(result.dataset.state, baseDataset.state); assert.equal(result.dataset.map_path, baseDataset.map_path); assert.equal(result.dataset.panorama_count, baseDataset.panorama_count); assert.equal(result.input.validated, true); assert.equal(result.job.state, "complete"); });