279 lines
10 KiB
TypeScript
279 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|