104 lines
6.5 KiB
TypeScript
104 lines
6.5 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import type { Pool } from 'mysql2/promise';
|
|
import { MysqlHistoryStore } from './store.js';
|
|
import { MAIN_SQL } from '../vehicles/repository.js';
|
|
import { HistoryStorageUnavailable } from './model.js';
|
|
import { testRow } from './fixtures.js';
|
|
|
|
/** Isolated transactional database double: failed transactions discard ALL staged writes. */
|
|
class Database {
|
|
commands:string[]=[];
|
|
rows=[testRow('9223372036854775000'),testRow('stock','3')];
|
|
settings='2026-10-19T13:00:00+08:00';
|
|
locked=1; existing: Array<{id:string;status:string}>=[];
|
|
committed:string[]=[]; staged:string[]=[];
|
|
failDetails=0; failReads=false; failCommitAck=false; beforeLock?:()=>void; afterSource?:()=>void; onReadTransaction?:()=>void;
|
|
released=false; clock=()=>new Date('2026-10-31T17:00:00.500+08:00');
|
|
async query(sql:string,_args?:unknown[]) {
|
|
this.commands.push(sql);
|
|
if(sql.includes('GET_LOCK')){this.beforeLock?.();return[[{acquired:this.locked}]];}
|
|
if(sql.includes('RELEASE_LOCK'))return[[{released:1}]];
|
|
if(sql.includes('FROM bi_asset_history_settings'))return[[{enabledAt:this.settings}]];
|
|
if(sql.includes('SELECT id,status'))return[this.existing];
|
|
if(sql.startsWith('START TRANSACTION'))this.onReadTransaction?.();
|
|
if(sql===MAIN_SQL){if(this.failReads)throw new Error('read failed');this.afterSource?.();return[this.rows];}
|
|
if(sql.startsWith('INSERT INTO bi_asset_history_vehicles')){
|
|
this.staged.push('details');if(this.failDetails-->0)throw new Error('write failed');
|
|
}
|
|
if(sql.startsWith('INSERT INTO bi_asset_history_batches'))this.staged.push('batch');
|
|
if(sql.startsWith('UPDATE bi_asset_history_batches'))this.staged.push('complete');
|
|
return[[]];
|
|
}
|
|
async getConnection(){return this;}
|
|
async beginTransaction(){this.commands.push('BEGIN');this.staged=[];}
|
|
async rollback(){this.commands.push('ROLLBACK');this.staged=[];}
|
|
async commit(){this.commands.push('COMMIT');this.committed.push(...this.staged);this.staged=[];if(this.failCommitAck){this.failCommitAck=false;this.existing=[{id:'committed',status:'COMPLETE'}];throw new Error('commit acknowledgement lost');}}
|
|
release(){this.released=true;}
|
|
store(){return new MysqlHistoryStore(this as unknown as Pool);}
|
|
}
|
|
test('capture uses uncached shared SQL and one consistent read; detail/header publish atomically',async()=>{
|
|
const db=new Database();assert.equal(await db.store().capture('2026-10',db.clock),'captured');
|
|
assert.deepEqual(db.committed,['batch','details','complete']);
|
|
assert.equal(db.commands.filter(s=>s===MAIN_SQL).length,1);
|
|
assert.ok(db.commands.indexOf('START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY')<db.commands.indexOf(MAIN_SQL));
|
|
assert.ok(db.commands.indexOf(MAIN_SQL)<db.commands.indexOf('ROLLBACK'));
|
|
assert.equal(db.released,true);assert.ok(db.commands.at(-1)!.includes('RELEASE_LOCK'));
|
|
});
|
|
test('write retry preserves the exact captured rows and time, even after the minute expires',async()=>{
|
|
const db=new Database();db.failDetails=1;let now=new Date('2026-10-31T17:00:30+08:00');
|
|
db.afterSource=()=>{now=new Date('2026-10-31T18:00:00+08:00');};const clock=()=>now;
|
|
assert.equal(await db.store().capture('2026-10',clock),'captured');
|
|
assert.equal(db.commands.filter(s=>s===MAIN_SQL).length,1);
|
|
assert.deepEqual(db.committed,['batch','details','complete']);
|
|
assert.equal(db.commands.filter(s=>s==='BEGIN').length,2);
|
|
});
|
|
test('lost commit acknowledgement does not overwrite or duplicate an already-published batch',async()=>{
|
|
const db=new Database();db.failCommitAck=true;
|
|
assert.equal(await db.store().capture('2026-10',db.clock),'already-complete');
|
|
assert.deepEqual(db.committed,['batch','details','complete']);assert.equal(db.commands.filter(s=>s===MAIN_SQL).length,1);
|
|
});
|
|
test('source is not read if transaction establishment crosses the capture deadline',async()=>{
|
|
const db=new Database();let now=new Date('2026-10-31T17:00:59.900+08:00');
|
|
db.onReadTransaction=()=>{now=new Date('2026-10-31T17:01:00+08:00');};
|
|
assert.equal(await db.store().capture('2026-10',()=>now),'outside-window');
|
|
assert.equal(db.commands.includes(MAIN_SQL),false);assert.deepEqual(db.committed,[]);
|
|
});
|
|
test('persistent detail-write failure publishes neither partial header nor rows',async()=>{
|
|
const db=new Database();db.failDetails=5;
|
|
await assert.rejects(()=>db.store().capture('2026-10',db.clock),/write failed/);
|
|
assert.deepEqual(db.committed,[]);assert.deepEqual(db.staged,[]);assert.equal(db.released,true);
|
|
assert.equal(db.commands.filter(s=>s===MAIN_SQL).length,1);
|
|
});
|
|
test('source failure and invalid rows cannot publish snapshots',async()=>{
|
|
const db=new Database();db.failReads=true;
|
|
await assert.rejects(()=>db.store().capture('2026-10',db.clock),/read failed/);assert.deepEqual(db.committed,[]);
|
|
db.failReads=false;db.rows=[testRow('duplicate'),testRow('duplicate')];
|
|
await assert.rejects(()=>db.store().capture('2026-10',db.clock),/重复/);assert.deepEqual(db.committed,[]);
|
|
});
|
|
test('completed period, concurrent lock and expired window cannot issue a source read',async()=>{
|
|
for(const scenario of ['complete','lock','expired','delayed-lock','late-activation']){
|
|
const db=new Database();let clock=db.clock;
|
|
if(scenario==='complete')db.existing=[{id:'existing',status:'COMPLETE'}];
|
|
if(scenario==='lock')db.locked=0;
|
|
if(scenario==='expired')clock=()=>new Date('2026-10-31T17:01:00+08:00');
|
|
if(scenario==='delayed-lock')db.beforeLock=()=>{clock=()=>new Date('2026-10-31T17:01:00+08:00');};
|
|
if(scenario==='late-activation')db.settings='2026-10-31T17:00:01+08:00';
|
|
const result=await db.store().capture('2026-10',()=>clock());
|
|
assert.ok(['already-complete','locked','outside-window'].includes(result));
|
|
assert.equal(db.commands.includes(MAIN_SQL),false,scenario);assert.deepEqual(db.committed,[]);
|
|
}
|
|
});
|
|
test('a legitimate empty source commits a zero-count complete batch without detail inserts',async()=>{
|
|
const db=new Database();db.rows=[];
|
|
assert.equal(await db.store().capture('2026-10',db.clock),'captured');assert.deepEqual(db.committed,['batch','complete']);
|
|
});
|
|
test('missing storage reads never issue schema writes and report storage-unavailable',async()=>{
|
|
const calls:string[]=[];
|
|
const db={query:async(sql:string)=>{calls.push(sql);throw{code:'ER_NO_SUCH_TABLE'};}};
|
|
const store=new MysqlHistoryStore(db as unknown as Pool);
|
|
await assert.rejects(()=>store.settings(),HistoryStorageUnavailable);
|
|
await assert.rejects(()=>store.batches(),HistoryStorageUnavailable);
|
|
assert.ok(calls.every(sql=>sql.startsWith('SELECT')));
|
|
});
|