Files
TREK/server/tests/unit/idempotency-cleanup.test.ts
T
Maurice e2ce2ad05c test(server): seed the service, MCP and route specs through the ORM factories
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.
2026-10-08 18:46:25 +02:00

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();
});
});