import { ConflictException, NotFoundException } from "@nestjs/common"; import { Prisma } from "@jorgecuadros/database"; import { NumidService } from "./numid.service"; /** * What matters about the allocator is the two things it must never do: hand the * same id to two customers, and hand out a recycled id while Access can still * take it back. Both are tested here; the emptiness SQL itself is exercised * against real data by scripts/numid-audit.mjs. */ interface Options { existingRef?: { legacyId: string } | null; archived?: boolean; missing?: boolean; recycle?: boolean; empty?: { numid: string; refId: string; customerId: string }[]; max?: number | null; /** Make the first N create() calls fail the unique key, as a race would. */ createConflicts?: number; /** Make updateMany report "nothing matched", as a lost recycle race would. */ recycleMisses?: number; } function build(opts: Options = {}) { const created: { legacyId: string }[] = []; let conflictsLeft = opts.createConflicts ?? 0; let missesLeft = opts.recycleMisses ?? 0; const prisma = { customer: { findUnique: jest.fn().mockResolvedValue( opts.missing ? null : { id: "cust-new", archivedAt: opts.archived ? new Date() : null }, ), }, customerLegacyRef: { findFirst: jest.fn().mockResolvedValue(opts.existingRef ?? null), updateMany: jest.fn().mockImplementation(() => { if (missesLeft > 0) { missesLeft -= 1; return Promise.resolve({ count: 0 }); } return Promise.resolve({ count: 1 }); }), create: jest.fn().mockImplementation(({ data }: { data: { legacyId: string } }) => { if (conflictsLeft > 0) { conflictsLeft -= 1; return Promise.reject( new Prisma.PrismaClientKnownRequestError("dup", { code: "P2002", clientVersion: "5", }), ); } created.push(data); return Promise.resolve(data); }), }, // Two different raw queries share one mock: the MAX lookup returns a single // {max} row, everything else is the empty-candidate list. $queryRaw: jest.fn().mockImplementation((sql: { strings?: string[]; sql?: string }) => { const text = String((sql as unknown as { sql?: string }).sql ?? ""); if (text.includes("MAX(")) return Promise.resolve([{ max: opts.max ?? null }]); return Promise.resolve(opts.empty ?? []); }), }; const settings = { numidRecycleEmpty: jest .fn() .mockResolvedValue({ value: opts.recycle ?? false, source: "default" }), }; return { service: new NumidService(prisma as never, settings as never), prisma, created, }; } describe("NUMid allocation", () => { it("returns the id a customer already holds instead of minting a second one", async () => { // A double-clicked button must not fork the customer's portal identity. const { service, prisma } = build({ existingRef: { legacyId: "501" } }); await expect(service.allocate("cust-new")).resolves.toEqual({ numid: "501", origin: "existing", }); expect(prisma.customerLegacyRef.create).not.toHaveBeenCalled(); }); it("allocates one past the highest id in the pool", async () => { const { service, created } = build({ max: 1171 }); await expect(service.allocate("cust-new")).resolves.toEqual({ numid: "1172", origin: "new", }); expect(created[0]).toMatchObject({ sourceSystem: "utilities", sourceTable: "DATGRAL", legacyId: "1172", }); }); it("starts at 1 when the pool is empty", async () => { const { service } = build({ max: null }); await expect(service.allocate("cust-new")).resolves.toMatchObject({ numid: "1" }); }); it("does NOT recycle while the setting is off, even with candidates free", async () => { // The default has to be the safe one: every reusable id still exists in // Access, and a --sync run reassigns it back to its Access owner. const { service, prisma } = build({ max: 1171, empty: [{ numid: "1089", refId: "ref-1089", customerId: "cust-old" }], }); await expect(service.allocate("cust-new")).resolves.toMatchObject({ numid: "1172", origin: "new", }); expect(prisma.customerLegacyRef.updateMany).not.toHaveBeenCalled(); }); it("takes the lowest empty id once recycling is switched on", async () => { const { service, prisma } = build({ recycle: true, max: 1171, empty: [ { numid: "1089", refId: "ref-1089", customerId: "cust-old" }, { numid: "1094", refId: "ref-1094", customerId: "cust-other" }, ], }); await expect(service.allocate("cust-new")).resolves.toEqual({ numid: "1089", origin: "recycled", previousCustomerId: "cust-old", }); // Guarded on the owner read a moment ago, so a ref that moved underneath us // matches nothing rather than being stolen. expect(prisma.customerLegacyRef.updateMany).toHaveBeenCalledWith({ where: { id: "ref-1089", customerId: "cust-old" }, data: { customerId: "cust-new" }, }); }); it("skips a candidate that someone else took first", async () => { const { service } = build({ recycle: true, max: 1171, recycleMisses: 1, empty: [ { numid: "1089", refId: "ref-1089", customerId: "cust-old" }, { numid: "1094", refId: "ref-1094", customerId: "cust-other" }, ], }); await expect(service.allocate("cust-new")).resolves.toMatchObject({ numid: "1094", origin: "recycled", }); }); it("falls back to a new id when recycling is on but nothing is free", async () => { const { service } = build({ recycle: true, max: 1171, empty: [] }); await expect(service.allocate("cust-new")).resolves.toMatchObject({ numid: "1172", origin: "new", }); }); it("retries when two writers pick the same id", async () => { // The unique key on (sourceSystem, sourceTable, legacyId) is what decides // the winner; the loser must retry, never overwrite. const { service, prisma } = build({ max: 1171, createConflicts: 1 }); await expect(service.allocate("cust-new")).resolves.toMatchObject({ numid: "1172", origin: "new", }); expect(prisma.customerLegacyRef.create).toHaveBeenCalledTimes(2); }); it("gives up loudly rather than looping forever", async () => { const { service } = build({ max: 1171, createConflicts: 99 }); await expect(service.allocate("cust-new")).rejects.toBeInstanceOf(ConflictException); }); it("refuses an archived customer", async () => { const { service } = build({ archived: true }); await expect(service.allocate("cust-new")).rejects.toBeInstanceOf(ConflictException); }); it("refuses a customer that does not exist", async () => { const { service } = build({ missing: true }); await expect(service.allocate("nope")).rejects.toBeInstanceOf(NotFoundException); }); });