gongxiangp2/scripts/recover-material-previews.test.mjs

76 lines
6.3 KiB
JavaScript

import test from 'node:test'
import assert from 'node:assert/strict'
import { OPERATION_ID, digest, json, planRecovery, applyRecovery, validateRecovery } from './recover-material-previews.mjs'
function fixture() {
const body = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WEBP'), Buffer.alloc(128)])
const sha = digest(body)
const manifest = { version: 1, reviewed: true, operationId: OPERATION_ID, previewQuality: 'temporary-160px-derived', actorId: '11111111-1111-4111-8111-111111111111', materials: Array.from({ length: 11 }, (_, i) => {
const materialId = `22222222-2222-4222-8222-${String(i).padStart(12, '0')}`
const destinationKey = `由壹点AI/public-display/${OPERATION_ID}/${materialId}-${sha}.webp`
return { materialId, name: `material-${i}`, oldPreviewUrl: `https://legacy.example.test/${i}.webp`, sourceSha256: '0'.repeat(64), cacheSha256: sha, bodyBase64: body.toString('base64'), bytes: body.length, width: 160, height: 160, provenance: '20261019-sharp-rotate-inside160-webp80', destinationKey, newPreviewUrl: `https://s3.jiansun.vip/gongxiangpan/${destinationKey}` }
}) }
let records = manifest.materials.map(r => ({ id: r.materialId, productModel: r.name, materialType: '材质', deletedAt: null, previewUrl: r.oldPreviewUrl, updatedAt: '2026-01-01T00:00:00.000Z', zipFilePath: 'untouched.zip', zipFileSize: '100', assets: [{ id: 'source', fileUrl: 'original.png' }] }))
let logs = []
const stored = new Map()
const hooks = { puts: 0, failCas: -1, failLog: false, badPublic: false, actorActive: true }
const db = {
actor: async () => ({ isActive: hooks.actorActive, isSuperAdmin: true, status: hooks.actorActive ? 'active' : 'disabled', employmentStatus: 'active', archivedAt: null }),
records: async () => structuredClone(records),
operationCount: async () => logs.length,
compareAndSet: async (row, updatedAt) => {
const index = records.findIndex(r => r.id === row.materialId && r.previewUrl === row.oldPreviewUrl && r.updatedAt === updatedAt)
if (index < 0 || index === hooks.failCas) return 0
records[index] = { ...records[index], previewUrl: row.newPreviewUrl, updatedAt: '2026-10-23T00:00:00.000Z' }
return 1
},
log: async row => { if (hooks.failLog) throw new Error('log failure'); logs.push(row) },
transaction: async fn => {
const before = structuredClone(records), previousLogs = structuredClone(logs)
try { await fn(db) } catch (error) { records = before; logs = previousLogs; throw error }
},
}
const objects = { get: async key => stored.get(key) ?? null, putIfAbsent: async (key, bytes) => { assert.equal(stored.has(key), false); stored.set(key, bytes); hooks.puts++ }, publicGet: async url => hooks.badPublic ? Buffer.from('wrong') : stored.get(decodeURIComponent(new URL(url).pathname).slice('/gongxiangpan/'.length)) }
const prepare = async () => { const plan = await planRecovery(manifest, db); return { manifest, plan, persistedPlanSha256: digest(json(plan)), db, objects } }
return { manifest, db, objects, hooks, stored, prepare, records: () => records, logs: () => logs }
}
test('requires reviewed exact count, hashes and scoped canonical destinations', () => {
const f = fixture(); assert.equal(validateRecovery(f.manifest), f.manifest)
for (const mutate of [m => { m.reviewed = false }, m => m.materials.pop(), m => { m.materials[0].cacheSha256 = 'f'.repeat(64) }, m => { m.materials[0].destinationKey = 'original.webp' }, m => { m.materials[0].materialId = m.materials[1].materialId }]) {
const m = structuredClone(f.manifest); mutate(m); assert.throws(() => validateRecovery(m))
}
})
test('successful recovery changes previews only, preserves source fields and creates eleven audits', async () => {
const f = fixture(), before = structuredClone(f.records()); const result = await applyRecovery(await f.prepare())
assert.equal(result.updated, 11); assert.equal(f.logs().length, 11); assert.equal(f.hooks.puts, 11)
for (let i = 0; i < 11; i++) { assert.equal(f.records()[i].zipFilePath, before[i].zipFilePath); assert.deepEqual(f.records()[i].assets, before[i].assets); assert.match(f.logs()[i].changes, /temporary-160px-derived/) }
})
test('rejects missing persisted snapshot proof and changed snapshot before any object write', async () => {
const f = fixture(), args = await f.prepare(); args.persistedPlanSha256 = 'wrong'
await assert.rejects(applyRecovery(args), /persisted/); assert.equal(f.hooks.puts, 0)
const args2 = await f.prepare(); f.records()[0].productModel = 'concurrent edit'
await assert.rejects(applyRecovery(args2), /source record changed/); assert.equal(f.hooks.puts, 0)
})
test('public readback mismatch cannot change database', async () => {
const f = fixture(), args = await f.prepare(); f.hooks.badPublic = true
await assert.rejects(applyRecovery(args), /public object/); assert.equal(f.logs().length, 0); assert.equal(f.records()[0].previewUrl, f.manifest.materials[0].oldPreviewUrl)
})
test('existing different object cannot be overwritten', async () => {
const f = fixture(), args = await f.prepare(); f.stored.set(f.manifest.materials[0].destinationKey, Buffer.from('different'))
await assert.rejects(applyRecovery(args), /refusing overwrite/); assert.equal(f.hooks.puts, 0); assert.equal(f.logs().length, 0)
})
test('CAS conflict rolls back every row and audit, keeps named objects without deleting', async () => {
const f = fixture(), before = structuredClone(f.records()), args = await f.prepare(); f.hooks.failCas = 6
await assert.rejects(applyRecovery(args), /concurrent record/); assert.deepEqual(f.records(), before); assert.equal(f.logs().length, 0); assert.equal(f.stored.size, 11)
})
test('audit failure rolls back all database changes', async () => {
const f = fixture(), before = structuredClone(f.records()), args = await f.prepare(); f.hooks.failLog = true
await assert.rejects(applyRecovery(args), /log failure/); assert.deepEqual(f.records(), before); assert.equal(f.logs().length, 0)
})
test('revoked actor blocks planning and duplicate operation never writes objects again', async () => {
const f = fixture(); f.hooks.actorActive = false; await assert.rejects(f.prepare(), /actor/)
f.hooks.actorActive = true; const args = await f.prepare(); await applyRecovery(args)
await assert.rejects(applyRecovery(args)); assert.equal(f.hooks.puts, 11)
})