import test from 'node:test'; import assert from 'node:assert/strict'; import { Hono } from 'hono'; import type { AuthUser } from '../../auth/types.js'; import { createPreviewMiddleware } from '../../auth/preview/routes.js'; import { createHistoryRouter } from './index.js'; import { HistoryService, HistoryInputError, HistoryPeriodUnavailable } from './service.js'; import { HistoryStorageUnavailable } from './model.js'; import { MemoryHistoryReader, testVehicle } from './fixtures.js'; const user = (permissionLevel: AuthUser['permissionLevel'] = 'full'): AuthUser => ({ userId:'manager-a',loginName:'a',userName:'甲',depCode:'a',depName:'部门甲',permissionLevel,roles:[] }); const now = () => new Date('2026-10-19T13:00:00+08:00'); const inventory = testVehicle('stock',{ operationStatus:'3',status:'Abnormal',departmentName:'部门乙',managerId:'manager-b',subjectOrg:'历史公司' }); const leased = testVehicle('own'); const foreign = testVehicle('foreign',{ managerId:'manager-b',departmentName:'部门乙' }); function setup() { const store = new MemoryHistoryReader(); store.add('2026-09',[leased,foreign,inventory]); const live = [testVehicle('today'), testVehicle('today-stock',{ operationStatus:'4',status:'Pending' })]; return { store, live, service:new HistoryService(store,async()=>live,now) }; } test('monthly holes/future remain null; current values are real; comparisons never skip missing periods', async () => { const {store,service}=setup(); store.add('2026-07',[leased]); const result=await service.trend(user(),{year:'2026'}); assert.equal(result.points.length,12); assert.deepEqual(result.points[8].counts,{total:3,operating:2,inventory:1}); assert.equal(result.points[8].difference,null); // August is missing, July cannot substitute. assert.deepEqual(result.points[9].difference,{total:-1,operating:-1,inventory:0}); assert.equal(result.points[9].state,'LIVE'); assert.equal(result.points[0].counts,null); assert.equal(result.points[10].state,'FUTURE'); assert.equal(result.points[10].counts,null); assert.ok(result.subjects.includes('历史公司')); }); test('annual counts use only December snapshots, not a sum of months', async () => { const {store,service}=setup(); store.add('2025-12',[leased]); store.add('2025-11',[leased,foreign,inventory]); const result=await service.trend(user(),{mode:'annual'}); assert.deepEqual(result.points.find(p=>p.period==='2025')!.counts,{total:1,operating:1,inventory:0}); assert.equal(result.points.at(-1)!.state,'LIVE'); assert.deepEqual(result.points.at(-1)!.difference,{total:1,operating:0,inventory:1}); }); test('full/department/personal and manager aliases share snapshot/live public inventory policy', async () => { const {service}=setup(); for (const level of ['full','department','personal'] as const) { const result=await service.trend(user(level),{}); const september=result.points[8]; assert.equal(september.counts!.total,level==='full'?3:2); const detail=await service.details(user(level),{month:'2026-09'}); assert.equal(detail.total,september.counts!.total); assert.deepEqual(detail.counts,september.counts); assert.ok(detail.items.some(v=>v.id==='stock')); assert.equal(detail.items.some(v=>v.id==='foreign'),level==='full'); assert.ok(!JSON.stringify(detail).includes('测试客户公司')); } const alias={...user('personal'),userId:'new-id',managerIds:['manager-a']}; assert.equal((await service.details(alias,{month:'2026-09'})).total,2); }); test('company filter intersects permission; foreign batch IDs cannot bypass month or identity', async () => { const {service}=setup(); const trend=await service.trend(user('personal'),{subject:'历史公司'}); assert.deepEqual(trend.points[8].counts,{total:1,operating:0,inventory:1}); assert.equal((await service.details(user('personal'),{month:'2026-09',subject:'公司甲'})).total,1); assert.equal((await service.details(user('personal'),{month:'2026-09',subject:'任意公司'})).total,0); await assert.rejects(()=>service.details(user(),{month:'2026-09',batchId:'other-batch'}),HistoryPeriodUnavailable); }); test('historical vehicles survive current deletion, reassignment and exit without any live join', async () => { const {store}=setup(); let calls=0; const service=new HistoryService(store,async()=>{calls++;throw new Error('live must not be read');},now); const result=await service.details(user('personal'),{month:'2026-09'}); assert.deepEqual(result.items.map(v=>v.id).sort(),['own','stock']); assert.equal(calls,0); assert.equal((await service.trend(user(),{year:'2025'})).points.every(p=>p.state==='MISSING'),true); assert.equal(calls,0); }); test('pagination/search/category use authorized snapshot only, keeping unsearched counts intact', async () => { const {store,service}=setup();store.add('2026-08',Array.from({length:65},(_,i)=>testVehicle(String(i)))); const detail=await service.details(user(),{month:'2026-08',page:'2',pageSize:'30'}); assert.equal(detail.total,65);assert.equal(detail.items.length,30);assert.equal(detail.counts!.total,65); const filtered=await service.details(user(),{month:'2026-09',category:'inventory',search:'TESTVINstock'}); assert.equal(filtered.total,1);assert.equal(filtered.counts!.total,3); }); test('missing storage degrades to live without any DDL; genuine database errors are not hidden', async () => { const {store,live}=setup();store.settings=async()=>{throw new HistoryStorageUnavailable();};store.batches=async()=>{throw new HistoryStorageUnavailable();}; const service=new HistoryService(store,async()=>live,now); const result=await service.trend(user(),{});assert.equal(result.storageReady,false);assert.equal(result.points[9].counts!.total,2);assert.equal(result.points[8].counts,null); await assert.rejects(()=>service.details(user(),{month:'2026-09'}),HistoryPeriodUnavailable); store.batches=async()=>{throw new Error('database offline');};await assert.rejects(()=>service.trend(user(),{}),/offline/); }); test('once cutoff passes live values cannot replace missing history, even when capture was not enabled', async () => { const {store}=setup();store.settingsValue=null;let liveCalls=0; const service=new HistoryService(store,async()=>{liveCalls++;return[leased];},()=>new Date('2026-10-31T17:00:30+08:00')); const result=await service.trend(user(),{});assert.equal(result.points[9].state,'MISSING');assert.equal(result.points[9].counts,null); assert.equal(liveCalls,0);await assert.rejects(()=>service.details(user(),{month:'2026-10'}),HistoryPeriodUnavailable); }); test('snapshot count integrity failure never appears as a legitimate zero', async () => { const {store,service}=setup();store.rows.clear();await assert.rejects(()=>service.trend(user(),{}),/integrity/); }); test('invalid parameters fail before querying details or live; valid archived zero retains detail access', async () => { const {store,service}=setup();store.add('2026-08',[]); assert.equal((await service.details(user(),{month:'2026-08'})).total,0); for(const params of [{year:'NaN'},{year:'2027'},{mode:'weekly'}] as Record[]) await assert.rejects(()=>service.trend(user(),params),HistoryInputError); for(const params of [{month:'2026-13'},{month:'2026-09',page:'0'},{month:'2026-09',pageSize:'101'},{month:'2026-09',category:'hacked'}] as Record[]) await assert.rejects(()=>service.details(user(),params),HistoryInputError); }); test('HTTP router authenticates, validates and exposes only read routes with private no-store', async () => { const {service}=setup();const router=createHistoryRouter(service); assert.equal((await router.request('/trend')).status,401); const app=new Hono<{Variables:{user:AuthUser}}>();app.use('*',async(c,next)=>{c.set('user',user('personal'));await next();});app.route('/',router); const response=await app.request('/trend?year=2026');assert.equal(response.status,200);assert.match(response.headers.get('Cache-Control')!,/private.*no-store/); assert.equal((await app.request('/details?month=2026-08')).status,409); assert.equal((await app.request('/trend?year=x')).status,400); assert.equal((await app.request('/details?month=2026-09&batchId=forged')).status,409); assert.equal((await app.request('/capture',{method:'POST'})).status,404); }); test('permission-preview identity applies to history GET; capture/import writes remain blocked', async () => { const {service}=setup();const audit:string[]=[]; const preview={current:async()=>({user:user('personal'),sessionId:'test-session'}),audit:async(event:{event:string})=>{audit.push(event.event);}}; const app=new Hono<{Variables:{user:AuthUser}}>();app.use('*',async(c,next)=>{c.set('user',user());await next();}); app.use('*',createPreviewMiddleware(preview as never));app.route('/api/asset-history',createHistoryRouter(service)); const response=await app.request('/api/asset-history/details?month=2026-09',{headers:{'X-BI-Preview':'isolated-test'}}); assert.equal(response.status,200);assert.equal((await response.json()).total,2); assert.equal((await app.request('/api/asset-history/import',{method:'POST',headers:{'X-BI-Preview':'isolated-test'}})).status,403); assert.deepEqual(audit,['read','blocked-write']); });