Files
iios/packages/iios-service/src/idempotency/idempotency.service.spec.ts
T
maaz519 b8b7e0d3e8 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>
2026-07-03 02:19:03 +05:30

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