import { describe, expect, it } from 'vitest'; import { buildCleanupInput, parseOptions, scanScopes, } from './spacetime-clean-editor-image-asset-kind.mjs'; describe('普通图片 assetKind 清理脚本', () => { it('默认 dry-run 并要求调用方显式选择 apply', () => { expect( parseOptions(['--database', 'genarrative-prod', '--server', 'prod'], {}), ).toMatchObject({ apply: false, chunkSize: 25, database: 'genarrative-prod', server: 'prod', }); }); it('编码 cursor、限制 canvas 批量并要求 apply hash', () => { expect( buildCleanupInput({ scope: 'asset', cursor: 'asset-25', limit: 25, dryRun: true, }), ).toEqual({ scope: 'asset', cursor: [0, 'asset-25'], limit: 25, dry_run: true, expected_batch_sha_256: null, }); expect(() => buildCleanupInput({ scope: 'canvas', limit: 6, dryRun: true }), ).toThrow('canvas scope limit'); expect(() => buildCleanupInput({ scope: 'showcase', limit: 25, dryRun: false }), ).toThrow('batch SHA-256'); }); it('按固定 scope 顺序执行 hash 绑定 apply 并保留字段计数', async () => { const calls: Array> = []; const callProcedure = async ( _options: Record, input: Record, ) => { calls.push(input); const dryRun = input.dry_run === true; const scope = String(input.scope); const hashDigit = { asset: 'a', 'project-resource': 'b', showcase: 'c', canvas: 'd', }[scope]!; return { ok: true, scope, dry_run: dryRun, scanned_count: 1, matched_count: 1, updated_count: dryRun ? 0 : 1, cleaned_field_count: scope === 'canvas' ? 3 : 1, blocker_count: 0, blocker_samples: [], next_cursor: null, has_more: false, batch_sha256: hashDigit.repeat(64), error_message: null, }; }; const summaries = await scanScopes( { chunkSize: 25 }, { apply: true, callProcedure }, ); expect(summaries.map((summary) => summary.scope)).toEqual([ 'asset', 'project-resource', 'showcase', 'canvas', ]); expect(calls.map((call) => `${call.scope}:${call.dry_run}`)).toEqual([ 'asset:true', 'asset:false', 'project-resource:true', 'project-resource:false', 'showcase:true', 'showcase:false', 'canvas:true', 'canvas:false', ]); expect(summaries.at(-1)?.cleaned_field_count).toBe(3); expect(calls.at(-2)?.limit).toBe(5); for (let index = 1; index < calls.length; index += 2) { expect(calls[index]?.expected_batch_sha_256).toEqual([ 0, String( calls[index - 1]?.scope === 'asset' ? 'a' : calls[index - 1]?.scope === 'project-resource' ? 'b' : calls[index - 1]?.scope === 'showcase' ? 'c' : 'd', ).repeat(64), ]); } }); it('拒绝 apply 后计数漂移与复核残留', async () => { const driftingCall = async ( _options: Record, input: Record, ) => ({ ok: true, scope: input.scope, dry_run: input.dry_run, scanned_count: 1, matched_count: 1, updated_count: input.dry_run ? 0 : 1, cleaned_field_count: input.dry_run ? 2 : 1, blocker_count: 0, blocker_samples: [], next_cursor: null, has_more: false, batch_sha256: 'a'.repeat(64), error_message: null, }); await expect( scanScopes( { chunkSize: 25 }, { apply: true, callProcedure: driftingCall }, ), ).rejects.toThrow('清理字段数'); const residualCall = async ( _options: Record, input: Record, ) => ({ ok: true, scope: input.scope, dry_run: true, scanned_count: 1, matched_count: 1, updated_count: 0, cleaned_field_count: 1, blocker_count: 0, blocker_samples: [], next_cursor: null, has_more: false, batch_sha256: 'b'.repeat(64), error_message: null, }); await expect( scanScopes( { chunkSize: 25 }, { verifyZero: true, callProcedure: residualCall }, ), ).rejects.toThrow('apply 后复核仍有'); }); it('报告游标循环时不泄露原始标识', async () => { const privateCursor = 'private-asset-id'; const loopingCall = async ( _options: Record, input: Record, ) => ({ ok: true, scope: input.scope, dry_run: true, scanned_count: 1, matched_count: 0, updated_count: 0, cleaned_field_count: 0, blocker_count: 0, blocker_samples: [], next_cursor: privateCursor, has_more: true, batch_sha256: 'c'.repeat(64), error_message: null, }); let message = ''; try { await scanScopes({ chunkSize: 25 }, { callProcedure: loopingCall }); } catch (error) { message = error instanceof Error ? error.message : String(error); } expect(message).toContain('SHA-256'); expect(message).not.toContain(privateCursor); }); });