WFDASH-WEBAPP/src/lib/server/stock-purchases-migration.test.ts
SJCoetz e9cf8aaf04 feat(stock): implement stock management workflow
This commit adds the complete v2/v3 stock management functionality:
- add new stock-specific item statuses and update the status visual map
- implement full stock purchase API suite (create, list, retrieve, send, receive)
- reorganize sidebar navigation to group stock-related pages together
- add stock item CRUD APIs, status utilities, low stock filtering, and surplus booking logic
- add database migration for the `stock_items.target_qty` field
- update type definitions and add comprehensive tests for all new stock components
2026-07-15 21:53:20 +02:00

298 lines
9.9 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from "vitest";
// --- Mock for the migration's PB needs ---------------------------------------
// The migration script needs (a) authWithPassword on _superusers and
// (b) `collections.getOne`, `collections.getList`, `collections.create`,
// `collections.update`. We don't drive the full `run()` here — the
// migration depends on a pre-existing `suppliers` and `stock_items`
// collection, which are out of scope for v1. Instead we test the
// verification logic by feeding it a fully-formed mock state and
// asserting the `✓`/`✗` lines that get logged.
const { collections, mockPb } = vi.hoisted(() => {
type Fields = Array<Record<string, unknown>>;
const state: {
collections: Map<string, {
id: string;
name: string;
fields: Fields;
indexes: string[];
}>;
} = {
collections: new Map(),
};
const collections = {
async getOne(nameOrId: string) {
// Real PB: getOne takes a collection id, but the migration
// script always passes `name` (because it knows the static
// name — "stock_purchases"). We accept both for testability.
const c =
state.collections.get(nameOrId) ||
[...state.collections.values()].find((v) => v.id === nameOrId);
if (!c) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
return c;
},
async getList(_page: number, _perPage: number, opts: { filter?: string } = {}) {
const filter = opts.filter || "";
const m = filter.match(/^name\s*=\s*"([^"]+)"$/);
if (!m) return { items: [] };
const c = state.collections.get(m[1]);
return { items: c ? [c] : [] };
},
async create(data: { name: string; fields: Fields; indexes?: string[] }) {
if (state.collections.has(data.name)) {
const err = new Error("already exists") as Error & { status: number };
err.status = 409;
throw err;
}
const c = {
id: `id-${data.name}`,
name: data.name,
fields: data.fields,
indexes: data.indexes ?? [],
};
state.collections.set(data.name, c);
return c;
},
async update(idOrName: string, data: { fields?: Fields; indexes?: string[] }) {
// Real PB: update is id-keyed. The migration script reads the
// id from getOne() and uses that for the update. We accept
// both id and name for testability (the test sometimes
// pre-seeds by name).
const c =
state.collections.get(idOrName) ||
[...state.collections.values()].find((v) => v.id === idOrName);
if (!c) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
if (data.fields) c.fields = data.fields;
if (data.indexes) c.indexes = data.indexes;
return c;
},
};
const mockPb = {
collection: (_name: string) => ({
// The migration only calls authWithPassword on the _superusers
// collection; the collections API handles everything else.
async authWithPassword(_email: string, _password: string) {
return { token: "mock-token" };
},
}),
collections,
};
return { collections: state.collections, mockPb };
});
vi.mock("pocketbase", () => {
// The migration calls `new PocketBase(url)`, so the default export
// needs to be constructable. A regular class wraps the mock
// instance so the constructor returns the same object every time.
return {
default: class MockPocketBase {
constructor() {
return mockPb;
}
},
};
});
// Stub the env so the migration's `getAdminPocketBase` is happy.
process.env.NEXT_PUBLIC_POCKETBASE_URL = "http://localhost:8090";
process.env.PB_ADMIN_EMAIL = "test@example.com";
process.env.PB_ADMIN_PASSWORD = "test";
// The migration lives outside `src/`, so a normal `@/...` alias
// won't reach it. Resolve via a relative path up to the repo root.
import { run } from "../../../scripts/migrations/2026-07-14-create-stock-purchases";
beforeEach(() => {
// Seed the pre-existing collections the migration needs to find.
// `suppliers` and `stock_items` were created by the earlier
// 2026-07-14-create-stock migration; both must exist before
// stock_purchases is created.
collections.set("suppliers", {
id: "id-suppliers",
name: "suppliers",
fields: [],
indexes: [],
});
collections.set("stock_items", {
id: "id-stock_items",
name: "stock_items",
fields: [],
indexes: [],
});
// Clear stock_purchases + stock_purchase_items so each test
// starts from a clean slate.
collections.delete("stock_purchases");
collections.delete("stock_purchase_items");
});
// Capture console.log so we can assert the verify block's output.
function captureLog() {
const lines: string[] = [];
const orig = console.log;
console.log = (...args: unknown[]) => {
lines.push(args.join(" "));
};
return {
lines,
restore: () => {
console.log = orig;
},
};
}
describe("2026-07-14-create-stock-purchases migration", () => {
it("creates both collections on a fresh DB and verifies every field + index", async () => {
const cap = captureLog();
try {
await run();
} finally {
cap.restore();
}
// Both collections now exist.
expect(collections.has("stock_purchases")).toBe(true);
expect(collections.has("stock_purchase_items")).toBe(true);
// Every required field is present on stock_purchases.
const sp = collections.get("stock_purchases")!;
const spFieldNames = (sp.fields as Array<{ name: string }>).map((f) => f.name);
expect(spFieldNames).toEqual(
expect.arrayContaining([
"purchase_number",
"supplier",
"status",
"notes",
"email_payload",
"email_error",
"email_message_id",
]),
);
// Every required field is present on stock_purchase_items.
const spi = collections.get("stock_purchase_items")!;
const spiFieldNames = (spi.fields as Array<{ name: string }>).map(
(f) => f.name,
);
expect(spiFieldNames).toEqual(
expect.arrayContaining([
"purchase",
"stock_item",
"description",
"quantity",
"quantity_received",
"cost_price",
"notes",
]),
);
// Indexes are present on both.
expect(sp.indexes.some((i) => i.includes("idx_sp_status"))).toBe(true);
expect(spi.indexes.some((i) => i.includes("idx_spi_purchase"))).toBe(true);
expect(spi.indexes.some((i) => i.includes("idx_spi_stock_item"))).toBe(true);
// Verify block output — every `✓` line printed.
const output = cap.lines.join("\n");
expect(output).toMatch(/✓ stock_purchases exists/);
expect(output).toMatch(/✓ stock_purchases\.purchase_number exists/);
expect(output).toMatch(/✓ stock_purchases\.supplier exists/);
expect(output).toMatch(/✓ stock_purchases\.status exists/);
expect(output).toMatch(/✓ stock_purchases\.status includes draft/);
expect(output).toMatch(/✓ stock_purchases\.status includes sent/);
expect(output).toMatch(/✓ stock_purchases\.status includes received/);
expect(output).toMatch(/✓ stock_purchases\.status includes cancelled/);
expect(output).toMatch(/✓ stock_purchase_items exists/);
expect(output).toMatch(/✓ stock_purchase_items\.purchase exists/);
expect(output).toMatch(/✓ stock_purchase_items\.stock_item exists/);
expect(output).toMatch(/Migration complete\./);
});
it("is idempotent — a second run does not error and the verify still passes", async () => {
// First run: creates the collections.
const cap1 = captureLog();
try {
await run();
} finally {
cap1.restore();
}
// Snapshot the post-first-run state.
const spAfter1 = collections.get("stock_purchases")!;
const spiAfter1 = collections.get("stock_purchase_items")!;
const spFieldCount = spAfter1.fields.length;
const spiFieldCount = spiAfter1.fields.length;
// Second run: should be a no-op (no duplicate create, no schema
// drift). The verify block still prints all ✓ lines.
const cap2 = captureLog();
try {
await run();
} finally {
cap2.restore();
}
const spAfter2 = collections.get("stock_purchases")!;
const spiAfter2 = collections.get("stock_purchase_items")!;
expect(spAfter2.fields.length).toBe(spFieldCount);
expect(spiAfter2.fields.length).toBe(spiFieldCount);
const output = cap2.lines.join("\n");
// No ✗ lines on a re-run.
expect(output).not.toMatch(/✗/);
expect(output).toMatch(/Migration complete\./);
});
it("patches a partial schema (missing fields are added, no duplicates)", async () => {
// Pre-seed stock_purchases with ONLY a couple of the expected
// fields. The migration should add the missing ones without
// re-adding the ones that already exist.
collections.set("stock_purchases", {
id: "id-stock_purchases",
name: "stock_purchases",
fields: [
{ name: "purchase_number", type: "text", required: true, options: { max: 50 } },
{ name: "supplier", type: "relation", required: true, collectionId: "id-suppliers", maxSelect: 1 },
// missing: status, notes, email_payload, email_error, email_message_id
],
indexes: [],
});
const cap = captureLog();
try {
await run();
} finally {
cap.restore();
}
const sp = collections.get("stock_purchases")!;
const spFieldNames = (sp.fields as Array<{ name: string }>).map((f) => f.name);
expect(spFieldNames).toEqual(
expect.arrayContaining([
"purchase_number",
"supplier",
"status",
"notes",
"email_payload",
"email_error",
"email_message_id",
]),
);
// No duplicates.
expect(new Set(spFieldNames).size).toBe(spFieldNames.length);
const output = cap.lines.join("\n");
expect(output).toMatch(/Migration complete\./);
});
});