88 lines
7.3 KiB
TypeScript
88 lines
7.3 KiB
TypeScript
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});}
|
||
});
|