b8b7e0d3e8
Adds the doc-mandated IiosIdempotencyCommand table + a reusable IdempotencyService.run() that writes IN_FLIGHT at command start, replays the cached response on a same-key/same-body retry, rejects a same-key/different-body reuse (409), and retries a FAILED command. Tenant-scoped via @@unique([scopeId, commandName, idempotencyKey]) (KG-02/KG-06). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
90 lines
4.7 KiB
TypeScript
90 lines
4.7 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
import { ConflictException } from '@nestjs/common';
|
|
import { PrismaClient } from '@prisma/client';
|
|
import { resetDb } from '../test-utils/reset-db';
|
|
import { IdempotencyService } from './idempotency.service';
|
|
import type { PrismaService } from '../prisma/prisma.service';
|
|
|
|
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
|
|
const prisma = new PrismaClient({ datasources: { db: { url } } });
|
|
const asService = prisma as unknown as PrismaService;
|
|
|
|
const svc = () => new IdempotencyService(asService);
|
|
const CMD = 'test.command';
|
|
|
|
beforeAll(async () => { await prisma.$connect(); });
|
|
afterAll(async () => { await prisma.$disconnect(); });
|
|
beforeEach(async () => { await resetDb(prisma); });
|
|
|
|
describe('IdempotencyService (P9 — command ledger)', () => {
|
|
it('runs fn once, returns its result, and records a COMPLETED row with the response', async () => {
|
|
let calls = 0;
|
|
const result = await svc().run({ scopeId: 's1', commandName: CMD, key: 'k1', request: { a: 1 } }, async () => {
|
|
calls++;
|
|
return { id: 'r1', ok: true };
|
|
});
|
|
expect(result).toEqual({ id: 'r1', ok: true });
|
|
expect(calls).toBe(1);
|
|
const row = await prisma.iiosIdempotencyCommand.findFirstOrThrow({ where: { scopeId: 's1', commandName: CMD, idempotencyKey: 'k1' } });
|
|
expect(row.status).toBe('COMPLETED');
|
|
expect(row.response).toEqual({ id: 'r1', ok: true });
|
|
expect(row.completedAt).toBeTruthy();
|
|
});
|
|
|
|
it('replays the cached response on a duplicate (same request) without re-running fn', async () => {
|
|
let calls = 0;
|
|
const params = { scopeId: 's1', commandName: CMD, key: 'k1', request: { a: 1 } };
|
|
const first = await svc().run(params, async () => { calls++; return { id: 'r1' }; });
|
|
const second = await svc().run(params, async () => { calls++; return { id: 'r2-should-not-run' }; });
|
|
expect(second).toEqual(first);
|
|
expect(calls).toBe(1); // fn never ran the second time
|
|
expect(await prisma.iiosIdempotencyCommand.count()).toBe(1);
|
|
});
|
|
|
|
it('rejects the same key reused with a different request (fn not called)', async () => {
|
|
let calls = 0;
|
|
await svc().run({ scopeId: 's1', commandName: CMD, key: 'k1', request: { a: 1 } }, async () => { calls++; return { id: 'r1' }; });
|
|
await expect(
|
|
svc().run({ scopeId: 's1', commandName: CMD, key: 'k1', request: { a: 2 } }, async () => { calls++; return { id: 'r2' }; }),
|
|
).rejects.toBeInstanceOf(ConflictException);
|
|
expect(calls).toBe(1);
|
|
});
|
|
|
|
it('marks FAILED on throw + propagates, and a later run with the same key retries to COMPLETED', async () => {
|
|
let calls = 0;
|
|
const params = { scopeId: 's1', commandName: CMD, key: 'k1', request: { a: 1 } };
|
|
await expect(
|
|
svc().run(params, async () => { calls++; throw new Error('boom'); }),
|
|
).rejects.toThrow('boom');
|
|
let row = await prisma.iiosIdempotencyCommand.findFirstOrThrow({ where: { scopeId: 's1', commandName: CMD, idempotencyKey: 'k1' } });
|
|
expect(row.status).toBe('FAILED');
|
|
|
|
const retry = await svc().run(params, async () => { calls++; return { id: 'r-ok' }; });
|
|
expect(retry).toEqual({ id: 'r-ok' });
|
|
expect(calls).toBe(2); // failed attempt + successful retry
|
|
row = await prisma.iiosIdempotencyCommand.findFirstOrThrow({ where: { scopeId: 's1', commandName: CMD, idempotencyKey: 'k1' } });
|
|
expect(row.status).toBe('COMPLETED');
|
|
});
|
|
|
|
it('a concurrent IN_FLIGHT row → conflict (command in progress)', async () => {
|
|
await prisma.iiosIdempotencyCommand.create({
|
|
data: { scopeId: 's1', commandName: CMD, idempotencyKey: 'k1', requestHash: 'irrelevant', status: 'IN_FLIGHT' },
|
|
});
|
|
// Same requestHash so we reach the IN_FLIGHT branch, not the mismatch branch.
|
|
const { createHash } = await import('node:crypto');
|
|
const rh = createHash('sha256').update('{"a":1}').digest('hex');
|
|
await prisma.iiosIdempotencyCommand.updateMany({ where: { idempotencyKey: 'k1' }, data: { requestHash: rh } });
|
|
await expect(
|
|
svc().run({ scopeId: 's1', commandName: CMD, key: 'k1', request: { a: 1 } }, async () => ({ id: 'r' })),
|
|
).rejects.toThrow(/in progress/);
|
|
});
|
|
|
|
it('the same command+key under two different scopes are independent (tenant isolation)', async () => {
|
|
const a = await svc().run({ scopeId: 'scope-A', commandName: CMD, key: 'k1', request: { a: 1 } }, async () => ({ id: 'A' }));
|
|
const b = await svc().run({ scopeId: 'scope-B', commandName: CMD, key: 'k1', request: { a: 1 } }, async () => ({ id: 'B' }));
|
|
expect(a).toEqual({ id: 'A' });
|
|
expect(b).toEqual({ id: 'B' }); // B did not replay A's response
|
|
expect(await prisma.iiosIdempotencyCommand.count()).toBe(2);
|
|
});
|
|
});
|