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
298 lines
9.9 KiB
TypeScript
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\./);
|
|
});
|
|
});
|