Extends the parts domain with what the mockups already show but the
schema didn't cover yet:
- part_parameters replaces the parameters JSON blob (comment said
'promote to a table later') - text and min/typical/max+unit values,
so numeric specs stay filterable/sortable later without another
migration.
- distributors + part_distributors: one row per (part, distributor)
price, matching the LCSC/Mouser/Reichelt tab on the part detail page.
- projects + project_parts: BOM ('used in'), with a single status
(planned/building/built) driving the 'N planned' vs 'N built' wording
instead of two separate counters.
- pick_lists + pick_list_items: checklist for one build batch,
independent of projects since a pick list can be ad-hoc.
The below-minimum reorder list needs no new table - it's already the
existing belowMinimum filter on GET /v1/parts.
Verified by running both migrations against a real Postgres 18 (ICU
de-DE collation) and the existing test suite.
Signed-off-by: Peter Siegmund <mars3142@noreply.mars3142.dev>
70 lines
2.2 KiB
TypeScript
70 lines
2.2 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { ConflictError, NotFoundError } from "../src/lib/errors.js";
|
|
import type { Part, PartsRepository } from "../src/repositories/parts.repository.js";
|
|
import { createPartsService } from "../src/services/parts.service.js";
|
|
|
|
function fakePart(over: Partial<Part> = {}): Part {
|
|
return {
|
|
id: "00000000-0000-0000-0000-000000000001",
|
|
mpn: "ESP32-WROOM-32E",
|
|
manufacturer: "Espressif",
|
|
description: "",
|
|
categoryId: null,
|
|
package: null,
|
|
minStock: 5,
|
|
lcscId: null,
|
|
datasheetUrl: null,
|
|
photoUrl: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function repoStub(over: Partial<PartsRepository> = {}): PartsRepository {
|
|
return {
|
|
list: vi.fn(),
|
|
findById: vi.fn(),
|
|
findByLcscId: vi.fn(),
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
...over,
|
|
} as unknown as PartsRepository;
|
|
}
|
|
|
|
describe("partsService.get", () => {
|
|
it("returns the part when it exists", async () => {
|
|
const part = fakePart();
|
|
const service = createPartsService(repoStub({ findById: vi.fn().mockResolvedValue(part) }));
|
|
await expect(service.get(part.id)).resolves.toBe(part);
|
|
});
|
|
|
|
it("throws NotFoundError when missing", async () => {
|
|
const service = createPartsService(
|
|
repoStub({ findById: vi.fn().mockResolvedValue(undefined) }),
|
|
);
|
|
await expect(service.get("missing")).rejects.toBeInstanceOf(NotFoundError);
|
|
});
|
|
});
|
|
|
|
describe("partsService.create", () => {
|
|
it("rejects a duplicate LCSC id", async () => {
|
|
const service = createPartsService(
|
|
repoStub({ findByLcscId: vi.fn().mockResolvedValue(fakePart()) }),
|
|
);
|
|
await expect(service.create({ mpn: "X", lcscId: "C2913196" })).rejects.toBeInstanceOf(
|
|
ConflictError,
|
|
);
|
|
});
|
|
|
|
it("creates when the LCSC id is free", async () => {
|
|
const created = fakePart({ mpn: "X" });
|
|
const create = vi.fn().mockResolvedValue(created);
|
|
const service = createPartsService(
|
|
repoStub({ findByLcscId: vi.fn().mockResolvedValue(undefined), create }),
|
|
);
|
|
await expect(service.create({ mpn: "X", lcscId: "C2913196" })).resolves.toBe(created);
|
|
expect(create).toHaveBeenCalledWith(expect.objectContaining({ mpn: "X", lcscId: "C2913196" }));
|
|
});
|
|
});
|