120 lines
9.1 KiB
TypeScript
120 lines
9.1 KiB
TypeScript
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<string,string>[]) 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<string,string>[]) 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']);
|
|
});
|