165 lines
6.6 KiB
TypeScript
165 lines
6.6 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import test from 'node:test';
|
|
import type { Vehicle, VehicleRow } from '../../types.js';
|
|
import {
|
|
FLOW_STATS_SQL,
|
|
MAIN_SQL,
|
|
VehicleRepository,
|
|
WEEKLY_DETAIL_SQL,
|
|
WEEKLY_TRUCK_ID_SQLS,
|
|
} from './repository.js';
|
|
|
|
class FakeDatabase {
|
|
readonly calls: Array<{ sql: string; values?: any[] }> = [];
|
|
|
|
constructor(private readonly responses: any[][]) {}
|
|
|
|
async query<T = any[]>(sql: string, values?: any[]): Promise<[T, ...any[]]> {
|
|
this.calls.push({ sql, values });
|
|
return [this.responses.shift() as T];
|
|
}
|
|
}
|
|
|
|
function vehicle(id: string): Vehicle {
|
|
return { id } as Vehicle;
|
|
}
|
|
|
|
test('keeps all vehicle GET endpoints in their established order', () => {
|
|
const source = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8');
|
|
const paths = Array.from(
|
|
source.matchAll(/app\.get\('([^']+)'|app\.on\(\[([^\]]+)\],\s*'([^']+)'/g),
|
|
).flatMap((match) => match[1] ? [match[1]] : /'GET'/.test(match[2]) ? [match[3]] : []);
|
|
|
|
assert.deepEqual(paths, [
|
|
'/summary',
|
|
'/by-type',
|
|
'/by-batch',
|
|
'/inventory-analysis',
|
|
'/dept-stats',
|
|
'/region-stats',
|
|
'/customer-stats',
|
|
'/analysis',
|
|
'/list',
|
|
'/inventory-stats',
|
|
'/weekly-detail',
|
|
'/flow-stats',
|
|
'/subjects',
|
|
'/refresh',
|
|
'/debug',
|
|
'/region-chart',
|
|
]);
|
|
assert.doesNotMatch(source, /\bSELECT\b|pool\.query|MAIN_SQL/);
|
|
});
|
|
|
|
test('keeps the non-empty vehicle cache for less than 5 seconds', async () => {
|
|
let now = 100_000;
|
|
const db = new FakeDatabase([[{ id: 'first' }], [{ id: 'second' }]]);
|
|
const repository = new VehicleRepository(
|
|
db,
|
|
() => now,
|
|
(row: VehicleRow) => vehicle(row.id),
|
|
);
|
|
|
|
assert.deepEqual(await repository.getVehicles(), [vehicle('first')]);
|
|
now += 4_999;
|
|
assert.deepEqual(await repository.getVehicles(), [vehicle('first')]);
|
|
now += 1;
|
|
assert.deepEqual(await repository.getVehicles(), [vehicle('second')]);
|
|
assert.deepEqual(db.calls.map((call) => call.sql), [MAIN_SQL, MAIN_SQL]);
|
|
});
|
|
|
|
test('keeps empty vehicle results uncached', async () => {
|
|
const db = new FakeDatabase([[], []]);
|
|
const repository = new VehicleRepository(db, () => 100_000);
|
|
|
|
await repository.getVehicles();
|
|
await repository.getVehicles();
|
|
|
|
assert.equal(db.calls.length, 2);
|
|
});
|
|
|
|
test('refresh invalidates vehicles but retains the weekly truck-id cache', async () => {
|
|
const db = new FakeDatabase([
|
|
[{ id: 'first' }],
|
|
[{ truck_id: 'pending' }],
|
|
[{ truck_id: 'delivered' }],
|
|
[{ truck_id: 'returned' }],
|
|
[{ truck_id: 'replaced' }, { truck_id: null }],
|
|
[{ id: 'second' }],
|
|
]);
|
|
const repository = new VehicleRepository(
|
|
db,
|
|
() => 100_000,
|
|
(row: VehicleRow) => vehicle(row.id),
|
|
);
|
|
|
|
await repository.getVehicles();
|
|
const weeklyBefore = await repository.getWeeklyTruckIds();
|
|
repository.invalidateRefreshCaches();
|
|
assert.deepEqual(await repository.getVehicles(), [vehicle('second')]);
|
|
assert.equal(await repository.getWeeklyTruckIds(), weeklyBefore);
|
|
assert.deepEqual(db.calls.map((call) => call.sql), [MAIN_SQL, ...WEEKLY_TRUCK_ID_SQLS, MAIN_SQL]);
|
|
assert.deepEqual([...weeklyBefore.replaced], ['replaced']);
|
|
});
|
|
|
|
test('keeps weekly detail SQL selection and flow date parameters unchanged', async () => {
|
|
const db = new FakeDatabase([[{ truck_id: '1' }], [{ id: 'delivered-1' }]]);
|
|
const repository = new VehicleRepository(db);
|
|
|
|
assert.deepEqual(await repository.getWeeklyDetailRows('new'), [{ truck_id: '1' }]);
|
|
assert.deepEqual(await repository.getFlowDetailRows('2026-08-01', '2026-08-13'), [{ id: 'delivered-1' }]);
|
|
assert.deepEqual(db.calls, [
|
|
{ sql: WEEKLY_DETAIL_SQL.new, values: undefined },
|
|
{
|
|
sql: FLOW_STATS_SQL,
|
|
values: ['2026-08-01', '2026-08-13', '2026-08-01', '2026-08-13'],
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('flow dates and inclusive day boundaries use operations signing times, retaining creation time only for display', () => {
|
|
for (const [alias, field] of [['dv', 'delivery_completion_processed_time'], ['r', 'return_completion_processed_time']]) {
|
|
assert.ok(FLOW_STATS_SQL.includes(`DATE_FORMAT(${alias}.${field}, '%Y-%m-%d') AS stat_date`));
|
|
assert.ok(FLOW_STATS_SQL.includes(`AND ${alias}.${field} IS NOT NULL`));
|
|
assert.ok(FLOW_STATS_SQL.includes(`AND ${alias}.${field} >= ?`));
|
|
assert.ok(FLOW_STATS_SQL.includes(`AND ${alias}.${field} < DATE_ADD(?, INTERVAL 1 DAY)`));
|
|
assert.ok(FLOW_STATS_SQL.includes(`DATE_FORMAT(${alias}.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time`));
|
|
}
|
|
assert.doesNotMatch(FLOW_STATS_SQL, /AND \w+\.create_time/);
|
|
assert.ok(FLOW_STATS_SQL.includes('ORDER BY flow.event_time DESC, flow.id DESC'));
|
|
});
|
|
|
|
test('flow retains multiple business events for the same vehicle', async () => {
|
|
const events = [
|
|
{ id: 'delivered-1', truck_id: '1', type: 'delivered', stat_date: '2026-09-16' },
|
|
{ id: 'delivered-2', truck_id: '1', type: 'delivered', stat_date: '2026-09-16' },
|
|
{ id: 'returned-1', truck_id: '1', type: 'returned', stat_date: '2026-09-16' },
|
|
];
|
|
const repository = new VehicleRepository(new FakeDatabase([events]));
|
|
assert.deepEqual(await repository.getFlowDetailRows('2026-09-16', '2026-09-16'), events);
|
|
});
|
|
|
|
|
|
test('replacement trips come exclusively from linked delivery records', () => {
|
|
assert.ok(FLOW_STATS_SQL.includes("CASE WHEN dv.vehicle_replacement_id IS NOT NULL THEN 'replaced' ELSE 'delivered' END AS type"));
|
|
assert.ok(FLOW_STATS_SQL.includes("CASE WHEN dv.vehicle_replacement_id IS NOT NULL THEN '替换交车' ELSE '交车' END AS type_label"));
|
|
assert.equal((FLOW_STATS_SQL.match(/FROM delivery_vehicle/g) || []).length, 1);
|
|
assert.doesNotMatch(FLOW_STATS_SQL, /FROM vehicle_replacement|dv\.delivery_time|r\.arrival_time|vr\.replace_time/);
|
|
});
|
|
|
|
test('delivery and replacement trips include awaiting customer signature and exclude suspended subjects', () => {
|
|
const [deliveries, returns] = FLOW_STATS_SQL.split('UNION ALL');
|
|
assert.ok(deliveries.includes('AND dv.delivery_status IN (2,3,5,20)'));
|
|
assert.ok(deliveries.includes('AND (dts.status IS NULL OR dts.status <> 10)'));
|
|
assert.doesNotMatch(deliveries, /dts\.status\s*(?:=|IN)|dv\.delivery_status IN \(2,3,5\)/);
|
|
assert.ok(returns.includes('AND r.status IN (2,3,5)'));
|
|
});
|
|
|
|
test('return history includes completed, signed and awaiting-signature statuses without arrival or settlement requirements', () => {
|
|
const returns = FLOW_STATS_SQL.split('UNION ALL')[1];
|
|
assert.ok(returns.includes('AND r.status IN (2,3,5)'));
|
|
assert.ok(returns.includes('AND r.return_completion_processed_time >= ?'));
|
|
assert.doesNotMatch(returns, /r\.is_arrived|r\.arrival_time|settlement|AND dts\.status|AND \(dts\.status/);
|
|
});
|