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'){ 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'))); });