import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { generateSharedNumber, generateOfferNumber, previewSharedNumber, isSharedNumberTaken, previewOfferNumber, isOfferNumberTaken, generateProjectNumber, previewProjectNumber, isProjectNumberTaken, } from "../services/numbering.service"; import prisma from "../config/database"; const YEAR = new Date().getFullYear(); /** * Assert two consecutive document numbers (a) keep an identical format / year * prefix and (b) increment by exactly one — without hard-coding which * `company_settings` pattern is configured. * * Some patterns are entirely digits ({YY}{CODE}{NNNN}), so a regex split of * "trailing digit run" is ambiguous. Instead we find the longest common prefix * of the two strings (the unchanged format/year portion) and parse the * remaining differing suffixes as integers — these are the rendered sequences. */ function assertStrictIncrement(num1: string, num2: string) { expect(num1).not.toBe(num2); expect(num2.length).toBe(num1.length); // same width ⇒ same format let i = 0; while (i < num1.length && num1[i] === num2[i]) i++; const commonPrefix = num1.slice(0, i); // The format/year portion is shared and non-empty (the numbers don't differ // from the very first character). expect(commonPrefix.length).toBeGreaterThan(0); const seq1 = Number(num1.slice(i)); const seq2 = Number(num2.slice(i)); expect(Number.isNaN(seq1)).toBe(false); expect(Number.isNaN(seq2)).toBe(false); expect(seq2).toBe(seq1 + 1); // strict +1, not merely !== } /** * Seed the (type, year) sequence row to a high `last_number` so the next * generated numbers land far above any real order/quotation in the test DB — * the generator's skip-taken retry then never fires and the strict +1 * assertion is deterministic. */ async function seedSequence(type: string, lastNumber: number) { await prisma.number_sequences.deleteMany({ where: { type } }); await prisma.number_sequences.create({ data: { type, year: YEAR, last_number: lastNumber }, }); } describe("generateSharedNumber", () => { beforeEach(async () => { await prisma.number_sequences.deleteMany({ where: { type: "shared" } }); }); afterEach(async () => { await prisma.number_sequences.deleteMany({ where: { type: "shared" } }); }); it("returns a non-empty string", async () => { const num = await generateSharedNumber(); expect(typeof num).toBe("string"); expect(num.length).toBeGreaterThan(0); }); it("increments the sequence by exactly 1 and preserves the year/format", async () => { // Seed high so neither generated number collides with a real order/project // → the skip-taken retry never fires and the increment is strictly +1. await seedSequence("shared", 900000); const num1 = await generateSharedNumber(); const num2 = await generateSharedNumber(); assertStrictIncrement(num1, num2); }); }); describe("generateProjectNumber", () => { beforeEach(async () => { await prisma.number_sequences.deleteMany({ where: { type: "project" } }); }); afterEach(async () => { await prisma.number_sequences.deleteMany({ where: { type: "project" } }); }); it("returns a non-empty string", async () => { const num = await generateProjectNumber(); expect(typeof num).toBe("string"); expect(num.length).toBeGreaterThan(0); }); it("increments the sequence by exactly 1 and preserves the year/format", async () => { // Seed high so neither generated number collides with a real project → // the skip-taken retry never fires and the increment is strictly +1. await seedSequence("project", 900000); const num1 = await generateProjectNumber(); const num2 = await generateProjectNumber(); assertStrictIncrement(num1, num2); }); it("previewProjectNumber returns a number not yet taken by a project", async () => { const preview = await previewProjectNumber(); expect(typeof preview).toBe("string"); expect(preview.length).toBeGreaterThan(0); expect(await isProjectNumberTaken(preview)).toBe(false); }); }); describe("cross-sequence isolation (shared vs project)", () => { beforeEach(async () => { await prisma.number_sequences.deleteMany({ where: { type: { in: ["shared", "project"] } }, }); }); afterEach(async () => { await prisma.number_sequences.deleteMany({ where: { type: { in: ["shared", "project"] } }, }); }); it("generateSharedNumber advances ONLY the shared row; generateProjectNumber ONLY the project row", async () => { // Seed the two sequences to DISTINCT known highs so neither generated // number collides with a real row (skip-taken retry never fires) and a // cross-contamination bug is unambiguous. await seedSequence("shared", 900000); await seedSequence("project", 500000); await generateSharedNumber(); // After a shared allocation only the shared row may have moved. let shared = await prisma.number_sequences.findFirst({ where: { type: "shared", year: YEAR }, select: { last_number: true }, }); let project = await prisma.number_sequences.findFirst({ where: { type: "project", year: YEAR }, select: { last_number: true }, }); expect(shared?.last_number).toBe(900001); expect(project?.last_number).toBe(500000); await generateProjectNumber(); // After a project allocation only the project row may have moved; the // shared row must still sit at 900001. shared = await prisma.number_sequences.findFirst({ where: { type: "shared", year: YEAR }, select: { last_number: true }, }); project = await prisma.number_sequences.findFirst({ where: { type: "project", year: YEAR }, select: { last_number: true }, }); expect(shared?.last_number).toBe(900001); expect(project?.last_number).toBe(500001); }); }); describe("generateOfferNumber", () => { beforeEach(async () => { await prisma.number_sequences.deleteMany({ where: { type: "offer" } }); }); afterEach(async () => { await prisma.number_sequences.deleteMany({ where: { type: "offer" } }); }); it("returns a non-empty string", async () => { const num = await generateOfferNumber(); expect(typeof num).toBe("string"); expect(num.length).toBeGreaterThan(0); }); it("increments the sequence by exactly 1 and preserves the year/format", async () => { await seedSequence("offer", 900000); const num1 = await generateOfferNumber(); const num2 = await generateOfferNumber(); assertStrictIncrement(num1, num2); }); }); describe("previewSharedNumber", () => { let createdProjectId: number | null = null; beforeEach(async () => { await prisma.number_sequences.deleteMany({ where: { type: "shared" } }); }); afterEach(async () => { if (createdProjectId !== null) { await prisma.projects .delete({ where: { id: createdProjectId } }) .catch(() => {}); createdProjectId = null; } await prisma.number_sequences.deleteMany({ where: { type: "shared" } }); }); it("returns a number not already used by an order or project", async () => { const preview = await previewSharedNumber(); expect(typeof preview).toBe("string"); expect(await isSharedNumberTaken(preview)).toBe(false); }); it("skips a number already taken when the sequence counter lags behind", async () => { // Seed the counter HIGH so the preview lands far above any real // order/project in the test DB. Without this, a real row could already // hold the first sequence number — making the create collide on the unique // column, or making `second !== first` pass for the wrong reason (because // the counter happened to already point past `first`). await seedSequence("shared", 900000); const first = await previewSharedNumber(); // Preconditions: the seeded preview really is free (so the create below is // safe) and is exactly what the generator would assign next. expect(await isSharedNumberTaken(first)).toBe(false); const project = await prisma.projects.create({ data: { project_number: first, name: "test-preview-skip-taken" }, }); createdProjectId = project.id; // The counter still lags behind (no row was consumed), but `first` is now // taken — the preview must advance past it instead of re-offering it. const second = await previewSharedNumber(); expect(second).not.toBe(first); expect(await isSharedNumberTaken(second)).toBe(false); }); }); describe("previewOfferNumber", () => { let createdQuotationId: number | null = null; beforeEach(async () => { await prisma.number_sequences.deleteMany({ where: { type: "offer" } }); }); afterEach(async () => { if (createdQuotationId !== null) { await prisma.quotations .delete({ where: { id: createdQuotationId } }) .catch(() => {}); createdQuotationId = null; } await prisma.number_sequences.deleteMany({ where: { type: "offer" } }); }); it("returns a number not already used by a quotation", async () => { const preview = await previewOfferNumber(); expect(typeof preview).toBe("string"); expect(await isOfferNumberTaken(preview)).toBe(false); }); it("skips a number already taken when the sequence counter lags behind", async () => { // Seed HIGH so the preview lands above any real quotation in the test DB // (see the shared-number test for the rationale): the create can't collide // and the skip-advance is genuinely exercised rather than passing because a // real row already advanced the preview. await seedSequence("offer", 900000); const first = await previewOfferNumber(); expect(await isOfferNumberTaken(first)).toBe(false); const quotation = await prisma.quotations.create({ data: { quotation_number: first }, }); createdQuotationId = quotation.id; // The counter still lags, but `first` is now taken — the preview must // advance past it instead of re-offering it. const second = await previewOfferNumber(); expect(second).not.toBe(first); expect(await isOfferNumberTaken(second)).toBe(false); }); });