import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { AuthUser } from '../../auth/types.js'; import { MemoryHistoryReader, testVehicle } from './fixtures.js'; import { HistoryService, HistoryPeriodUnavailable } from './service.js'; import { FileReconstructionReader, publishReconstruction, reconstructionPoint, validateReconstruction, type ReconstructionData } from './reconstruction.js'; const user = (permissionLevel: AuthUser['permissionLevel'] = 'full'): AuthUser => ({ userId:'1',userName:'测试',loginName:'test',depName:'甲',depCode:'a',permissionLevel,roles:[] }); const now = () => new Date('2026-10-19T14:38:33+08:00'); const data = (): ReconstructionData => ({ schemaVersion:1,method:'HANDOVER_REVIEW_V1',workbookSha256:'a'.repeat(64),sourceAsOf:'2026-10-19T14:38:33+08:00',sourceReference:'测试核对表.xlsx', periods:[{month:'2026-08',operating:1,inventory:0,unclassified:2,currentlyExited:1,vehicles:[{id:'90071992547409931',plateNumber:'历史车牌',vin:'OLDVIN',disposition:'operating',basis:'交车 D:1,2026-08-01 10:00:00'}]}, {month:'2026-09',operating:0,inventory:1,unclassified:2,currentlyExited:1,vehicles:[{id:'90071992547409931',plateNumber:'历史车牌',vin:'OLDVIN',disposition:'inventory',basis:'还车 R:2,2026-09-01 10:00:00'}]}] }); function setup(value = data()) { const store = new MemoryHistoryReader(); let reads = 0; const service = new HistoryService(store,async()=>[testVehicle('today')],now,{read:async()=>{reads++;return value;}}); return { store,service,reads:()=>reads }; } test('reconstruction preserves unknown total, source date and string IDs without claiming a captured snapshot', async()=>{ const {service}=setup(); const trend=await service.trend(user(),{year:'2026'}); const p=trend.points[8]; assert.equal(p.state,'RECONSTRUCTED');assert.equal(p.counts,null);assert.equal(p.asOf,null); assert.equal(p.reconstruction!.operating,0);assert.equal(p.reconstruction!.inventory,1); assert.deepEqual(p.reconstruction!.difference,{operating:-1,inventory:1}); assert.equal(trend.points[7].reconstruction!.difference,null); assert.equal(trend.points[9].state,'LIVE');assert.equal(trend.points[9].difference,null); assert.equal(trend.points[10].state,'FUTURE'); const detail=await service.details(user(),{month:'2026-09',batchId:p.batchId!}); assert.equal(detail.counts,null);assert.equal(detail.items[0].id,'90071992547409931'); assert.equal(detail.items[0].plateNumber,'历史车牌');assert.equal(detail.items[0].subjectOrg,null); assert.equal(detail.items[0].operationStatus,null);assert.equal(detail.items[0].customerName,null); assert.match(detail.items[0].reconstructionBasis!,/R:2/); }); test('company/department/personal scopes cannot load or query unowned reconstruction rows', async()=>{ for(const [level,params] of [['department',{}],['personal',{}],['full',{subject:'任意公司'}]] as const){ const {service,reads}=setup();const identity=user(level); const trend=await service.trend(identity,{year:'2026',...params});assert.equal(trend.points[8].state,'MISSING'); await assert.rejects(()=>service.details(identity,{month:'2026-09',batchId:`rec-${'a'.repeat(12)}-2026-09`,...params}),HistoryPeriodUnavailable); assert.equal(reads(),0); } }); test('formal archives take precedence and forged or stale reconstruction batch IDs are refused',async()=>{ const {service,store}=setup();store.add('2026-09',[testVehicle('formal')]); const trend=await service.trend(user(),{});assert.equal(trend.points[8].state,'ARCHIVED');assert.equal(trend.points[8].reconstruction,undefined); await assert.rejects(()=>service.details(user(),{month:'2026-09',batchId:`rec-${'a'.repeat(12)}-2026-09`}),HistoryPeriodUnavailable); await assert.rejects(()=>service.details(user(),{month:'2026-08',batchId:'rec-forged'}),HistoryPeriodUnavailable); assert.equal((await service.details(user(),{month:'2026-09'})).items[0].id,'formal'); }); test('reconstructed category/search/pagination counts agree with the selected period; missing months stay absent',async()=>{ const value=data();value.periods[1].vehicles=Array.from({length:61},(_,i)=>({id:String(i+1),plateNumber:`历史${i}`,vin:`OLD${i}`,disposition:'inventory',basis:'还车 R:1'}));value.periods[1].inventory=61; const {service}=setup(value); const all=await service.details(user(),{month:'2026-09',page:'2',pageSize:'30'});assert.equal(all.total,61);assert.equal(all.items.length,30); assert.equal((await service.details(user(),{month:'2026-09',category:'operating'})).total,0); assert.equal((await service.details(user(),{month:'2026-09',search:'OLD60'})).total,1); assert.equal((await service.trend(user(),{})).points[6].state,'MISSING'); }); test('annual estimates reuse December only; no sum, current-month replacement, or cross-source difference',async()=>{ const value=data();value.periods.push({...value.periods[0],month:'2025-12'}); const {service}=setup(value);const result=await service.trend(user(),{mode:'annual'}); assert.equal(result.points.find(p=>p.period==='2025')!.reconstruction!.operating,1); assert.equal(result.points.find(p=>p.period==='2026')!.state,'LIVE');assert.equal(result.points.at(-1)!.difference,null); assert.equal(reconstructionPoint(value,'2026-10',false,now()),null); }); test('schema rejects duplicates, unsafe IDs, invented counts, current/future months and missing provenance',()=>{ const mutate=[(d:ReconstructionData)=>{d.periods.push(d.periods[0]);},(d:ReconstructionData)=>{d.periods[0].vehicles[0].id=1 as never;}, (d:ReconstructionData)=>{d.periods[0].operating=99;},(d:ReconstructionData)=>{d.periods[0].month='2026-10';}, (d:ReconstructionData)=>{d.workbookSha256='';},(d:ReconstructionData)=>{d.periods[0].vehicles.push(d.periods[0].vehicles[0]);d.periods[0].operating=2;}, (d:ReconstructionData)=>{d.periods[0].unclassified=-1;}]; for(const change of mutate){const d=data();change(d);assert.throws(()=>validateReconstruction(d),/无效/);} const d=data();Object.assign(d.periods[0].vehicles[0],{subjectOrg:'今日主体',customerName:'不应导入的客户'}); assert.equal(JSON.stringify(validateReconstruction(d)).includes('今日主体'),false); const zero=data();zero.periods[0]={month:'2026-08',operating:0,inventory:0,unclassified:3,currentlyExited:0,vehicles:[]}; assert.equal(reconstructionPoint(validateReconstruction(zero),'2026-08',false,now())!.reconstruction!.operating,0); }); test('explicit local publication is atomic, idempotent and immutable; reader has no database dependencies',async()=>{ const dir=await mkdtemp(join(tmpdir(),'asset-reconstruction-')); try{ const path=join(dir,'history.json');const value=data(); const results=await Promise.all([publishReconstruction(path,value),publishReconstruction(path,value)]); assert.deepEqual(results.sort(),['created','unchanged']); const before=await readFile(path,'utf8');const different=data();different.workbookSha256='b'.repeat(64); await assert.rejects(()=>publishReconstruction(path,different),/禁止自动覆盖/);assert.equal(await readFile(path,'utf8'),before); assert.deepEqual(await new FileReconstructionReader(path).read(),validateReconstruction(value)); }finally{await rm(dir,{recursive:true,force:true});} });