Customers created in the staff UI had no NUMid and so could not log in to my.jorgecuadros.com at all: the id is a CustomerLegacyRef row, not a column, and create() deliberately writes none. Allocation is a staff action (POST /customers/:id/portal-access, MANAGER) rather than part of create, because insurance is expected to move to the platform before utilities and an insurance-only customer has no reason to spend a utilities id. The audit that decides which ids are reusable took three passes. "Owns no rows" matches nobody -- migration gave all 1,171 NUMids a property and a transaction. "No transaction in N years" also matches nobody -- every customer carries a synthetic Jan-1 opening-balance row, so everyone looks active this year. Subtracting that row is what makes dormancy measurable, and it leaves 4 never-used ids and 10 dormant ones on dev. Two further traps are encoded in the queries: insurance/DATGRAL is a separate id space that reuses the sourceTable name and runs past 4,000, and ACCOUNT CANCELED is a transaction line type, not an account state -- all 8 customers carrying it have current-year activity. Recycling ships switched off (numid.recycleEmpty, default false). Every reusable id still exists in Access DATGRAL, and a --sync run reassigns refs with ON DUPLICATE KEY UPDATE customerId, so an id recycled before the utilities cutover is silently handed back to its Access owner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
207 lines
6.9 KiB
TypeScript
207 lines
6.9 KiB
TypeScript
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);
|
|
});
|
|
});
|