mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
e2ce2ad05c
Service, MCP, integration and e2e suites with up to a dozen raw statements now seed and read through the factories. A buildApp() suite uses the app's own ORM, a partial-module e2e or a unit suite a createTestOrm() or sharedTestOrm() handle on its database. Reads that used to come back undefined now come back null, and the assertions say so; where a projected SELECT was compared whole, the same columns are picked off the row. Two reads inside the scheduler's delivery callback stay synchronous raw statements, marked, so they keep seeing the row at the moment of delivery. Two more database mocks lose a canAccessTrip/isOwner stand-in the module no longer exports.
138 lines
5.7 KiB
TypeScript
138 lines
5.7 KiB
TypeScript
/**
|
|
* Idempotency key TTL cleanup (H6).
|
|
*
|
|
* The TREK client replays queued mutations with their X-Idempotency-Key on
|
|
* reconnect, so the server must keep keys long enough to cover a realistic
|
|
* offline window — otherwise a key GC'd before the device returns lets the
|
|
* replay create a duplicate. The TTL was raised from 24h to 30d (overridable).
|
|
*/
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
|
import { createSnapshotTestDb } from '../helpers/db-mock';
|
|
import { createTestOrm, type TestOrm } from '../helpers/test-orm';
|
|
import { purgeExpiredIdempotencyKeys } from '../../src/nest/common/idempotency-cleanup';
|
|
import { IdempotencyCleanupJob } from '../../src/nest/common/idempotency-cleanup.job';
|
|
import { IdempotencyKeys } from '../../src/db/entities/IdempotencyKeys.entity';
|
|
import { countRows, deleteRows, findRows, insertRow } from '../helpers/factories/rows';
|
|
import type { IdempotencyKeysRepository } from '../../src/db/repositories/IdempotencyKeys.repository';
|
|
import type { CronRegistrarService } from '../../src/nest/scheduling/cron-registrar.service';
|
|
|
|
const DAY = 24 * 60 * 60;
|
|
const NOW = 2_000_000_000_000; // fixed ms so the test is deterministic
|
|
const NOW_SEC = Math.floor(NOW / 1000);
|
|
|
|
const testDb = createSnapshotTestDb();
|
|
let t: TestOrm;
|
|
let idempotencyKeys: IdempotencyKeysRepository;
|
|
|
|
beforeAll(async () => {
|
|
t = await createTestOrm(testDb);
|
|
idempotencyKeys = t.repo(IdempotencyKeys);
|
|
});
|
|
afterAll(async () => { await t.close(); testDb.close(); });
|
|
|
|
async function insertKey(key: string, ageSeconds: number, nowSec = NOW_SEC): Promise<void> {
|
|
await insertRow(t, IdempotencyKeys, {
|
|
key,
|
|
user: 1,
|
|
method: 'POST',
|
|
path: '/x',
|
|
status_code: 200,
|
|
response_body: '{}',
|
|
created_at: nowSec - ageSeconds,
|
|
});
|
|
}
|
|
|
|
/** The keys left in the table. */
|
|
async function remainingKeys(): Promise<string[]> {
|
|
return (await findRows(t, IdempotencyKeys)).map((r) => r.key);
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
testDb.pragma('foreign_keys = OFF'); // fixtures reference a user we don't seed here
|
|
await deleteRows(t, IdempotencyKeys);
|
|
t.clear();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await deleteRows(t, IdempotencyKeys);
|
|
testDb.pragma('foreign_keys = ON');
|
|
delete process.env.IDEMPOTENCY_TTL_SECONDS;
|
|
});
|
|
|
|
describe('purgeExpiredIdempotencyKeys', () => {
|
|
it('removes keys older than the 30-day default, keeps recent ones', async () => {
|
|
await insertKey('old', 31 * DAY);
|
|
await insertKey('fresh', 5 * DAY);
|
|
|
|
const removed = await purgeExpiredIdempotencyKeys(NOW, undefined, idempotencyKeys);
|
|
|
|
expect(removed).toBe(1);
|
|
expect(await remainingKeys()).toEqual(['fresh']);
|
|
});
|
|
|
|
it('keeps a 25-day-old key that the old 24h TTL would have dropped', async () => {
|
|
await insertKey('offline-trip', 25 * DAY);
|
|
expect(await purgeExpiredIdempotencyKeys(NOW, undefined, idempotencyKeys)).toBe(0);
|
|
expect(await countRows(t, IdempotencyKeys)).toBe(1);
|
|
});
|
|
|
|
it('respects the IDEMPOTENCY_TTL_SECONDS override', async () => {
|
|
process.env.IDEMPOTENCY_TTL_SECONDS = String(DAY);
|
|
await insertKey('twoDays', 2 * DAY);
|
|
expect(await purgeExpiredIdempotencyKeys(NOW, undefined, idempotencyKeys)).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('IdempotencyCleanupJob', () => {
|
|
function makeJob(enabled = true) {
|
|
const registrar = {
|
|
isEnabled: vi.fn(() => enabled),
|
|
// Spelled out rather than vi.fn(() => enabled), because the test below
|
|
// reads the third argument back off mock.calls: a no-parameter stub types
|
|
// every recorded call as [], and indexing it fails typecheck:tests.
|
|
register: vi.fn((_name: string, _expression: string, _onTick: () => void | Promise<void>) => enabled),
|
|
unregister: vi.fn(),
|
|
};
|
|
const job = new IdempotencyCleanupJob(idempotencyKeys, registrar as unknown as CronRegistrarService);
|
|
return { job, registrar };
|
|
}
|
|
|
|
it('registers the nightly 3 AM cron, and stays out of the registry under the test gate', async () => {
|
|
const on = makeJob();
|
|
on.job.onApplicationBootstrap();
|
|
expect(on.registrar.register).toHaveBeenCalledWith('idempotency-cleanup', '0 3 * * *', expect.any(Function));
|
|
|
|
// The registered callback IS the tick — drive it once over an empty table
|
|
// (nothing to purge → no log, no throw).
|
|
const onTick = on.registrar.register.mock.calls[0][2];
|
|
await expect(onTick()).resolves.toBeUndefined();
|
|
|
|
const off = makeJob(false);
|
|
off.job.onApplicationBootstrap();
|
|
expect(off.registrar.register).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('the tick purges through the injected IdempotencyKeysRepository', async () => {
|
|
// The tick uses the live clock, so these fixtures age against Date.now()
|
|
// (the pure-function cases above pin their own fixed NOW instead).
|
|
const liveNowSec = Math.floor(Date.now() / 1000);
|
|
await insertKey('old', 31 * DAY, liveNowSec);
|
|
await insertKey('fresh', 5 * DAY, liveNowSec);
|
|
|
|
const { job } = makeJob();
|
|
await job.tick();
|
|
expect(await remainingKeys()).toEqual(['fresh']);
|
|
});
|
|
|
|
it('a failing purge is contained to the Idempotency cleanup log line', async () => {
|
|
const broken = { deleteExpired: () => { throw new Error('db gone'); } } as unknown as IdempotencyKeysRepository;
|
|
const job = new IdempotencyCleanupJob(broken, { isEnabled: () => true } as unknown as CronRegistrarService);
|
|
await expect(job.tick()).resolves.toBeUndefined();
|
|
|
|
// Non-Error throws are stringified rather than crashing the catch itself.
|
|
const brokenString = { deleteExpired: () => { throw 'db string'; } } as unknown as IdempotencyKeysRepository;
|
|
const job2 = new IdempotencyCleanupJob(brokenString, { isEnabled: () => true } as unknown as CronRegistrarService);
|
|
await expect(job2.tick()).resolves.toBeUndefined();
|
|
});
|
|
});
|