feat(iios): idempotency-command ledger + IdempotencyService (P9)
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>
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user