From 1e4e74f8d2416192976c7b8bb94c3b6555062a40 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 15 Mar 2026 12:42:15 +0100 Subject: [PATCH] test(03-01): add failing tests for setup backend - Add setups and setupItems tables to DB schema - Add Zod schemas for setup create/update/sync - Add Setup/SetupItem types to shared types - Add setup tables to test helper - Write service and route tests (RED - no implementation yet) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/db/schema.ts | 21 +++ src/shared/schemas.ts | 13 ++ src/shared/types.ts | 12 +- tests/helpers/db.ts | 17 ++ tests/routes/setups.test.ts | 229 +++++++++++++++++++++++++++ tests/services/setup.service.test.ts | 192 ++++++++++++++++++++++ 6 files changed, 483 insertions(+), 1 deletion(-) create mode 100644 tests/routes/setups.test.ts create mode 100644 tests/services/setup.service.test.ts diff --git a/src/db/schema.ts b/src/db/schema.ts index b51bdb2..67b6b8a 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -63,6 +63,27 @@ export const threadCandidates = sqliteTable("thread_candidates", { .$defaultFn(() => new Date()), }); +export const setups = sqliteTable("setups", { + id: integer("id").primaryKey({ autoIncrement: true }), + name: text("name").notNull(), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), + updatedAt: integer("updated_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), +}); + +export const setupItems = sqliteTable("setup_items", { + id: integer("id").primaryKey({ autoIncrement: true }), + setupId: integer("setup_id") + .notNull() + .references(() => setups.id, { onDelete: "cascade" }), + itemId: integer("item_id") + .notNull() + .references(() => items.id, { onDelete: "cascade" }), +}); + export const settings = sqliteTable("settings", { key: text("key").primaryKey(), value: text("value").notNull(), diff --git a/src/shared/schemas.ts b/src/shared/schemas.ts index eb2e2dc..d73a1c9 100644 --- a/src/shared/schemas.ts +++ b/src/shared/schemas.ts @@ -48,3 +48,16 @@ export const updateCandidateSchema = createCandidateSchema.partial(); export const resolveThreadSchema = z.object({ candidateId: z.number().int().positive(), }); + +// Setup schemas +export const createSetupSchema = z.object({ + name: z.string().min(1, "Setup name is required"), +}); + +export const updateSetupSchema = z.object({ + name: z.string().min(1, "Setup name is required"), +}); + +export const syncSetupItemsSchema = z.object({ + itemIds: z.array(z.number().int().positive()), +}); diff --git a/src/shared/types.ts b/src/shared/types.ts index 685fd6e..85ae3c5 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -9,8 +9,11 @@ import type { createCandidateSchema, updateCandidateSchema, resolveThreadSchema, + createSetupSchema, + updateSetupSchema, + syncSetupItemsSchema, } from "./schemas.ts"; -import type { items, categories, threads, threadCandidates } from "../db/schema.ts"; +import type { items, categories, threads, threadCandidates, setups, setupItems } from "../db/schema.ts"; // Types inferred from Zod schemas export type CreateItem = z.infer; @@ -23,8 +26,15 @@ export type CreateCandidate = z.infer; export type UpdateCandidate = z.infer; export type ResolveThread = z.infer; +// Setup types +export type CreateSetup = z.infer; +export type UpdateSetup = z.infer; +export type SyncSetupItems = z.infer; + // Types inferred from Drizzle schema export type Item = typeof items.$inferSelect; export type Category = typeof categories.$inferSelect; export type Thread = typeof threads.$inferSelect; export type ThreadCandidate = typeof threadCandidates.$inferSelect; +export type Setup = typeof setups.$inferSelect; +export type SetupItem = typeof setupItems.$inferSelect; diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts index c7725f7..a500b35 100644 --- a/tests/helpers/db.ts +++ b/tests/helpers/db.ts @@ -58,6 +58,23 @@ export function createTestDb() { ) `); + sqlite.run(` + CREATE TABLE setups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) + ) + `); + + sqlite.run(` + CREATE TABLE setup_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + setup_id INTEGER NOT NULL REFERENCES setups(id) ON DELETE CASCADE, + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE + ) + `); + sqlite.run(` CREATE TABLE settings ( key TEXT PRIMARY KEY, diff --git a/tests/routes/setups.test.ts b/tests/routes/setups.test.ts new file mode 100644 index 0000000..6505fdf --- /dev/null +++ b/tests/routes/setups.test.ts @@ -0,0 +1,229 @@ +import { describe, it, expect, beforeEach } from "bun:test"; +import { Hono } from "hono"; +import { createTestDb } from "../helpers/db.ts"; +import { setupRoutes } from "../../src/server/routes/setups.ts"; +import { itemRoutes } from "../../src/server/routes/items.ts"; + +function createTestApp() { + const db = createTestDb(); + const app = new Hono(); + + app.use("*", async (c, next) => { + c.set("db", db); + await next(); + }); + + app.route("/api/setups", setupRoutes); + app.route("/api/items", itemRoutes); + return { app, db }; +} + +async function createSetupViaAPI(app: Hono, name: string) { + const res = await app.request("/api/setups", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + return res.json(); +} + +async function createItemViaAPI(app: Hono, data: any) { + const res = await app.request("/api/items", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + return res.json(); +} + +describe("Setup Routes", () => { + let app: Hono; + + beforeEach(() => { + const testApp = createTestApp(); + app = testApp.app; + }); + + describe("POST /api/setups", () => { + it("with valid body returns 201 + setup object", async () => { + const res = await app.request("/api/setups", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Day Hike" }), + }); + + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.name).toBe("Day Hike"); + expect(body.id).toBeGreaterThan(0); + }); + + it("with empty name returns 400", async () => { + const res = await app.request("/api/setups", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "" }), + }); + + expect(res.status).toBe(400); + }); + }); + + describe("GET /api/setups", () => { + it("returns array of setups with totals", async () => { + const setup = await createSetupViaAPI(app, "Backpacking"); + const item = await createItemViaAPI(app, { + name: "Tent", + categoryId: 1, + weightGrams: 1200, + priceCents: 30000, + }); + + // Sync items + await app.request(`/api/setups/${setup.id}/items`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ itemIds: [item.id] }), + }); + + const res = await app.request("/api/setups"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBeGreaterThanOrEqual(1); + expect(body[0].itemCount).toBeDefined(); + expect(body[0].totalWeight).toBeDefined(); + expect(body[0].totalCost).toBeDefined(); + }); + }); + + describe("GET /api/setups/:id", () => { + it("returns setup with items", async () => { + const setup = await createSetupViaAPI(app, "Day Hike"); + const item = await createItemViaAPI(app, { + name: "Water Bottle", + categoryId: 1, + weightGrams: 200, + priceCents: 2500, + }); + + await app.request(`/api/setups/${setup.id}/items`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ itemIds: [item.id] }), + }); + + const res = await app.request(`/api/setups/${setup.id}`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.name).toBe("Day Hike"); + expect(body.items).toHaveLength(1); + expect(body.items[0].name).toBe("Water Bottle"); + }); + + it("returns 404 for non-existent setup", async () => { + const res = await app.request("/api/setups/9999"); + expect(res.status).toBe(404); + }); + }); + + describe("PUT /api/setups/:id", () => { + it("updates setup name", async () => { + const setup = await createSetupViaAPI(app, "Original"); + + const res = await app.request(`/api/setups/${setup.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Renamed" }), + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.name).toBe("Renamed"); + }); + + it("returns 404 for non-existent setup", async () => { + const res = await app.request("/api/setups/9999", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Ghost" }), + }); + + expect(res.status).toBe(404); + }); + }); + + describe("DELETE /api/setups/:id", () => { + it("removes setup", async () => { + const setup = await createSetupViaAPI(app, "To Delete"); + + const res = await app.request(`/api/setups/${setup.id}`, { + method: "DELETE", + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.success).toBe(true); + + // Verify gone + const getRes = await app.request(`/api/setups/${setup.id}`); + expect(getRes.status).toBe(404); + }); + + it("returns 404 for non-existent setup", async () => { + const res = await app.request("/api/setups/9999", { method: "DELETE" }); + expect(res.status).toBe(404); + }); + }); + + describe("PUT /api/setups/:id/items", () => { + it("syncs items to setup", async () => { + const setup = await createSetupViaAPI(app, "Kit"); + const item1 = await createItemViaAPI(app, { name: "Item 1", categoryId: 1 }); + const item2 = await createItemViaAPI(app, { name: "Item 2", categoryId: 1 }); + + const res = await app.request(`/api/setups/${setup.id}/items`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ itemIds: [item1.id, item2.id] }), + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.success).toBe(true); + + // Verify items + const getRes = await app.request(`/api/setups/${setup.id}`); + const getBody = await getRes.json(); + expect(getBody.items).toHaveLength(2); + }); + }); + + describe("DELETE /api/setups/:id/items/:itemId", () => { + it("removes single item from setup", async () => { + const setup = await createSetupViaAPI(app, "Kit"); + const item1 = await createItemViaAPI(app, { name: "Item 1", categoryId: 1 }); + const item2 = await createItemViaAPI(app, { name: "Item 2", categoryId: 1 }); + + // Sync both items + await app.request(`/api/setups/${setup.id}/items`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ itemIds: [item1.id, item2.id] }), + }); + + // Remove one + const res = await app.request(`/api/setups/${setup.id}/items/${item1.id}`, { + method: "DELETE", + }); + + expect(res.status).toBe(200); + + // Verify only one remains + const getRes = await app.request(`/api/setups/${setup.id}`); + const getBody = await getRes.json(); + expect(getBody.items).toHaveLength(1); + expect(getBody.items[0].name).toBe("Item 2"); + }); + }); +}); diff --git a/tests/services/setup.service.test.ts b/tests/services/setup.service.test.ts new file mode 100644 index 0000000..2a9b6f2 --- /dev/null +++ b/tests/services/setup.service.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, beforeEach } from "bun:test"; +import { createTestDb } from "../helpers/db.ts"; +import { + getAllSetups, + getSetupWithItems, + createSetup, + updateSetup, + deleteSetup, + syncSetupItems, + removeSetupItem, +} from "../../src/server/services/setup.service.ts"; +import { createItem } from "../../src/server/services/item.service.ts"; + +describe("Setup Service", () => { + let db: ReturnType; + + beforeEach(() => { + db = createTestDb(); + }); + + describe("createSetup", () => { + it("creates setup with name, returns setup with id/timestamps", () => { + const setup = createSetup(db, { name: "Day Hike" }); + + expect(setup).toBeDefined(); + expect(setup.id).toBeGreaterThan(0); + expect(setup.name).toBe("Day Hike"); + expect(setup.createdAt).toBeDefined(); + expect(setup.updatedAt).toBeDefined(); + }); + }); + + describe("getAllSetups", () => { + it("returns setups with itemCount, totalWeight, totalCost", () => { + const setup = createSetup(db, { name: "Backpacking" }); + const item1 = createItem(db, { + name: "Tent", + categoryId: 1, + weightGrams: 1200, + priceCents: 30000, + }); + const item2 = createItem(db, { + name: "Sleeping Bag", + categoryId: 1, + weightGrams: 800, + priceCents: 20000, + }); + syncSetupItems(db, setup.id, [item1.id, item2.id]); + + const setups = getAllSetups(db); + expect(setups).toHaveLength(1); + expect(setups[0].name).toBe("Backpacking"); + expect(setups[0].itemCount).toBe(2); + expect(setups[0].totalWeight).toBe(2000); + expect(setups[0].totalCost).toBe(50000); + }); + + it("returns 0 for weight/cost when setup has no items", () => { + createSetup(db, { name: "Empty Setup" }); + + const setups = getAllSetups(db); + expect(setups).toHaveLength(1); + expect(setups[0].itemCount).toBe(0); + expect(setups[0].totalWeight).toBe(0); + expect(setups[0].totalCost).toBe(0); + }); + }); + + describe("getSetupWithItems", () => { + it("returns setup with full item details and category info", () => { + const setup = createSetup(db, { name: "Day Hike" }); + const item = createItem(db, { + name: "Water Bottle", + categoryId: 1, + weightGrams: 200, + priceCents: 2500, + }); + syncSetupItems(db, setup.id, [item.id]); + + const result = getSetupWithItems(db, setup.id); + expect(result).toBeDefined(); + expect(result!.name).toBe("Day Hike"); + expect(result!.items).toHaveLength(1); + expect(result!.items[0].name).toBe("Water Bottle"); + expect(result!.items[0].categoryName).toBe("Uncategorized"); + expect(result!.items[0].categoryEmoji).toBeDefined(); + }); + + it("returns null for non-existent setup", () => { + const result = getSetupWithItems(db, 9999); + expect(result).toBeNull(); + }); + }); + + describe("updateSetup", () => { + it("updates setup name, returns updated setup", () => { + const setup = createSetup(db, { name: "Original" }); + const updated = updateSetup(db, setup.id, { name: "Renamed" }); + + expect(updated).toBeDefined(); + expect(updated!.name).toBe("Renamed"); + }); + + it("returns null for non-existent setup", () => { + const result = updateSetup(db, 9999, { name: "Ghost" }); + expect(result).toBeNull(); + }); + }); + + describe("deleteSetup", () => { + it("removes setup and cascades to setup_items", () => { + const setup = createSetup(db, { name: "To Delete" }); + const item = createItem(db, { name: "Item", categoryId: 1 }); + syncSetupItems(db, setup.id, [item.id]); + + const deleted = deleteSetup(db, setup.id); + expect(deleted).toBe(true); + + // Setup gone + const result = getSetupWithItems(db, setup.id); + expect(result).toBeNull(); + }); + + it("returns false for non-existent setup", () => { + const result = deleteSetup(db, 9999); + expect(result).toBe(false); + }); + }); + + describe("syncSetupItems", () => { + it("sets items for a setup (delete-all + re-insert)", () => { + const setup = createSetup(db, { name: "Kit" }); + const item1 = createItem(db, { name: "Item 1", categoryId: 1 }); + const item2 = createItem(db, { name: "Item 2", categoryId: 1 }); + const item3 = createItem(db, { name: "Item 3", categoryId: 1 }); + + // Initial sync + syncSetupItems(db, setup.id, [item1.id, item2.id]); + let result = getSetupWithItems(db, setup.id); + expect(result!.items).toHaveLength(2); + + // Re-sync with different items + syncSetupItems(db, setup.id, [item2.id, item3.id]); + result = getSetupWithItems(db, setup.id); + expect(result!.items).toHaveLength(2); + const names = result!.items.map((i: any) => i.name).sort(); + expect(names).toEqual(["Item 2", "Item 3"]); + }); + + it("syncing with empty array clears all items", () => { + const setup = createSetup(db, { name: "Kit" }); + const item = createItem(db, { name: "Item", categoryId: 1 }); + syncSetupItems(db, setup.id, [item.id]); + + syncSetupItems(db, setup.id, []); + const result = getSetupWithItems(db, setup.id); + expect(result!.items).toHaveLength(0); + }); + }); + + describe("removeSetupItem", () => { + it("removes single item from setup", () => { + const setup = createSetup(db, { name: "Kit" }); + const item1 = createItem(db, { name: "Item 1", categoryId: 1 }); + const item2 = createItem(db, { name: "Item 2", categoryId: 1 }); + syncSetupItems(db, setup.id, [item1.id, item2.id]); + + removeSetupItem(db, setup.id, item1.id); + const result = getSetupWithItems(db, setup.id); + expect(result!.items).toHaveLength(1); + expect(result!.items[0].name).toBe("Item 2"); + }); + }); + + describe("cascade behavior", () => { + it("deleting a collection item removes it from all setups", () => { + const setup = createSetup(db, { name: "Kit" }); + const item1 = createItem(db, { name: "Item 1", categoryId: 1 }); + const item2 = createItem(db, { name: "Item 2", categoryId: 1 }); + syncSetupItems(db, setup.id, [item1.id, item2.id]); + + // Delete item1 from collection (need direct DB access) + const { items: itemsTable } = require("../../src/db/schema.ts"); + const { eq } = require("drizzle-orm"); + db.delete(itemsTable).where(eq(itemsTable.id, item1.id)).run(); + + const result = getSetupWithItems(db, setup.id); + expect(result!.items).toHaveLength(1); + expect(result!.items[0].name).toBe("Item 2"); + }); + }); +});