Layered: routes -> services -> repositories -> db, over PostgreSQL 18 with split roles (findr_migrator for DDL, findr_app for DML). Keycloak JWT auth with per-endpoint scope guards. Drizzle migrations run with the DDL role at container start. Pick-by-light: one ESP32 controller drives one WS2812 chain through several boxes; each box owns a contiguous LED slice. MQTT contract in src/lib/mqtt-topics.ts / docs/mqtt.md. /v1/pick controls light/idle/off and /v1/boxes + /v1/controllers manage the strip mapping. Reference resource /v1/parts wired end to end. Vitest, Biome, multi-stage Dockerfile, local compose.yml with Postgres. Signed-off-by: Peter Siegmund <mars3142@noreply.mars3142.dev>
141 lines
4.0 KiB
TypeScript
141 lines
4.0 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { ConflictError, ValidationError } from "../src/lib/errors.js";
|
|
import type { Box, BoxesRepository, Controller } from "../src/repositories/boxes.repository.js";
|
|
import { createBoxesService } from "../src/services/boxes.service.js";
|
|
|
|
const controller: Controller = {
|
|
id: "c1",
|
|
espId: "esp-a",
|
|
name: "Wall",
|
|
ledCount: 160,
|
|
online: true,
|
|
lastSeenAt: null,
|
|
firmware: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
function box(over: Partial<Box>): Box {
|
|
return {
|
|
id: "b?",
|
|
number: 1,
|
|
name: "Box",
|
|
controllerId: "c1",
|
|
ledOffset: 0,
|
|
ledCount: 40,
|
|
columns: 8,
|
|
rows: 5,
|
|
wiring: "progressive",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function repo(over: Partial<BoxesRepository> = {}): BoxesRepository {
|
|
return {
|
|
listControllers: vi.fn(),
|
|
findControllerById: vi.fn().mockResolvedValue(controller),
|
|
findControllerByEspId: vi.fn().mockResolvedValue(controller),
|
|
createController: vi.fn(),
|
|
updateController: vi.fn(),
|
|
upsertControllerByEspId: vi.fn(),
|
|
listBoxes: vi.fn(),
|
|
boxesForController: vi.fn().mockResolvedValue([]),
|
|
findBoxByNumber: vi.fn().mockResolvedValue(undefined),
|
|
createBox: vi.fn((d) => Promise.resolve(box(d))),
|
|
updateBox: vi.fn(),
|
|
setLocationLedIndex: vi.fn(),
|
|
...over,
|
|
} as unknown as BoxesRepository;
|
|
}
|
|
|
|
describe("boxesService.upsertBox — LED slice validation", () => {
|
|
it("rejects a slice that runs past the controller's chain", async () => {
|
|
const svc = createBoxesService(repo());
|
|
await expect(
|
|
svc.upsertBox({
|
|
number: 2,
|
|
name: "B2",
|
|
controllerEspId: "esp-a",
|
|
ledOffset: 140,
|
|
ledCount: 40,
|
|
}),
|
|
).rejects.toBeInstanceOf(ValidationError);
|
|
});
|
|
|
|
it("rejects a slice that overlaps a sibling box", async () => {
|
|
const svc = createBoxesService(
|
|
repo({
|
|
boxesForController: vi
|
|
.fn()
|
|
.mockResolvedValue([box({ number: 1, ledOffset: 0, ledCount: 40 })]),
|
|
}),
|
|
);
|
|
await expect(
|
|
svc.upsertBox({
|
|
number: 2,
|
|
name: "B2",
|
|
controllerEspId: "esp-a",
|
|
ledOffset: 20,
|
|
ledCount: 40,
|
|
}),
|
|
).rejects.toBeInstanceOf(ConflictError);
|
|
});
|
|
|
|
it("accepts a non-overlapping slice and returns its config", async () => {
|
|
const svc = createBoxesService(
|
|
repo({
|
|
boxesForController: vi
|
|
.fn()
|
|
.mockResolvedValue([box({ number: 1, ledOffset: 0, ledCount: 40 })]),
|
|
}),
|
|
);
|
|
const { config } = await svc.upsertBox({
|
|
number: 2,
|
|
name: "B2",
|
|
controllerEspId: "esp-a",
|
|
ledOffset: 40,
|
|
ledCount: 40,
|
|
wiring: "serpentine",
|
|
});
|
|
expect(config).toMatchObject({
|
|
boxNumber: 2,
|
|
controllerEspId: "esp-a",
|
|
ledOffset: 40,
|
|
wiring: "serpentine",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("boxesService.resolvePickTargets", () => {
|
|
it("uses a registered compartment's explicit LED index", async () => {
|
|
const b = {
|
|
...box({ number: 3, columns: 8, rows: 5, wiring: "progressive" }),
|
|
locations: [
|
|
{
|
|
id: "l1",
|
|
boxId: "b?",
|
|
code: "K3·D4",
|
|
column: "D",
|
|
row: 4,
|
|
ledIndex: 99,
|
|
description: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
],
|
|
};
|
|
const svc = createBoxesService(repo({ findBoxByNumber: vi.fn().mockResolvedValue(b) }));
|
|
const targets = await svc.resolvePickTargets(3, [{ cell: "D4", mode: "take" }]);
|
|
expect(targets).toEqual([{ cell: "D4", mode: "take", ledIndex: 99 }]);
|
|
});
|
|
|
|
it("falls back to the grid formula for an unregistered cell", async () => {
|
|
const b = { ...box({ number: 3, columns: 8, rows: 5, wiring: "progressive" }), locations: [] };
|
|
const svc = createBoxesService(repo({ findBoxByNumber: vi.fn().mockResolvedValue(b) }));
|
|
const targets = await svc.resolvePickTargets(3, [{ cell: "D4", mode: "put" }]);
|
|
expect(targets).toEqual([{ cell: "D4", mode: "put", ledIndex: 27 }]);
|
|
});
|
|
});
|