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