From 37c9999d07fee4d189b128aaa907895e4dbfd9d3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 15 Mar 2026 11:38:35 +0100 Subject: [PATCH] test(02-01): add failing integration tests for thread API routes - 14 integration tests covering all thread and candidate endpoints - Thread CRUD, candidate CRUD, and resolution endpoint tests - Covers error cases: 400 validation, 404 not found, resolved re-resolve Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/routes/threads.test.ts | 300 +++++++++++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 tests/routes/threads.test.ts diff --git a/tests/routes/threads.test.ts b/tests/routes/threads.test.ts new file mode 100644 index 0000000..6ec2741 --- /dev/null +++ b/tests/routes/threads.test.ts @@ -0,0 +1,300 @@ +import { describe, it, expect, beforeEach } from "bun:test"; +import { Hono } from "hono"; +import { createTestDb } from "../helpers/db.ts"; +import { threadRoutes } from "../../src/server/routes/threads.ts"; + +function createTestApp() { + const db = createTestDb(); + const app = new Hono(); + + // Inject test DB into context for all routes + app.use("*", async (c, next) => { + c.set("db", db); + await next(); + }); + + app.route("/api/threads", threadRoutes); + return { app, db }; +} + +async function createThreadViaAPI(app: Hono, name: string) { + const res = await app.request("/api/threads", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + return res.json(); +} + +async function createCandidateViaAPI(app: Hono, threadId: number, data: any) { + const res = await app.request(`/api/threads/${threadId}/candidates`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + return res.json(); +} + +describe("Thread Routes", () => { + let app: Hono; + + beforeEach(() => { + const testApp = createTestApp(); + app = testApp.app; + }); + + describe("POST /api/threads", () => { + it("with valid body returns 201 + thread object", async () => { + const res = await app.request("/api/threads", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "New Tent" }), + }); + + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.name).toBe("New Tent"); + expect(body.id).toBeGreaterThan(0); + expect(body.status).toBe("active"); + }); + + it("with empty name returns 400", async () => { + const res = await app.request("/api/threads", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "" }), + }); + + expect(res.status).toBe(400); + }); + }); + + describe("GET /api/threads", () => { + it("returns array of active threads with metadata", async () => { + const thread = await createThreadViaAPI(app, "Backpack Options"); + await createCandidateViaAPI(app, thread.id, { + name: "Pack A", + categoryId: 1, + priceCents: 20000, + }); + + const res = await app.request("/api/threads"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBeGreaterThanOrEqual(1); + expect(body[0].candidateCount).toBeDefined(); + }); + + it("?includeResolved=true includes archived threads", async () => { + const t1 = await createThreadViaAPI(app, "Active"); + const t2 = await createThreadViaAPI(app, "To Resolve"); + const candidate = await createCandidateViaAPI(app, t2.id, { + name: "Winner", + categoryId: 1, + }); + + // Resolve thread + await app.request(`/api/threads/${t2.id}/resolve`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ candidateId: candidate.id }), + }); + + // Default excludes resolved + const defaultRes = await app.request("/api/threads"); + const defaultBody = await defaultRes.json(); + expect(defaultBody).toHaveLength(1); + + // With includeResolved includes all + const allRes = await app.request("/api/threads?includeResolved=true"); + const allBody = await allRes.json(); + expect(allBody).toHaveLength(2); + }); + }); + + describe("GET /api/threads/:id", () => { + it("returns thread with candidates", async () => { + const thread = await createThreadViaAPI(app, "Tent Options"); + await createCandidateViaAPI(app, thread.id, { + name: "Tent A", + categoryId: 1, + priceCents: 30000, + }); + + const res = await app.request(`/api/threads/${thread.id}`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.name).toBe("Tent Options"); + expect(body.candidates).toHaveLength(1); + expect(body.candidates[0].name).toBe("Tent A"); + }); + + it("returns 404 for non-existent thread", async () => { + const res = await app.request("/api/threads/9999"); + expect(res.status).toBe(404); + }); + }); + + describe("PUT /api/threads/:id", () => { + it("updates thread name", async () => { + const thread = await createThreadViaAPI(app, "Original"); + + const res = await app.request(`/api/threads/${thread.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"); + }); + }); + + describe("DELETE /api/threads/:id", () => { + it("removes thread", async () => { + const thread = await createThreadViaAPI(app, "To Delete"); + + const res = await app.request(`/api/threads/${thread.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/threads/${thread.id}`); + expect(getRes.status).toBe(404); + }); + }); + + describe("POST /api/threads/:id/candidates", () => { + it("adds candidate, returns 201", async () => { + const thread = await createThreadViaAPI(app, "Test"); + + const res = await app.request(`/api/threads/${thread.id}/candidates`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "Candidate A", + categoryId: 1, + priceCents: 25000, + weightGrams: 500, + }), + }); + + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.name).toBe("Candidate A"); + expect(body.threadId).toBe(thread.id); + }); + }); + + describe("PUT /api/threads/:threadId/candidates/:candidateId", () => { + it("updates candidate", async () => { + const thread = await createThreadViaAPI(app, "Test"); + const candidate = await createCandidateViaAPI(app, thread.id, { + name: "Original", + categoryId: 1, + }); + + const res = await app.request( + `/api/threads/${thread.id}/candidates/${candidate.id}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Updated" }), + }, + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.name).toBe("Updated"); + }); + }); + + describe("DELETE /api/threads/:threadId/candidates/:candidateId", () => { + it("removes candidate", async () => { + const thread = await createThreadViaAPI(app, "Test"); + const candidate = await createCandidateViaAPI(app, thread.id, { + name: "To Remove", + categoryId: 1, + }); + + const res = await app.request( + `/api/threads/${thread.id}/candidates/${candidate.id}`, + { method: "DELETE" }, + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.success).toBe(true); + }); + }); + + describe("POST /api/threads/:id/resolve", () => { + it("with valid candidateId returns 200 + created item", async () => { + const thread = await createThreadViaAPI(app, "Tent Decision"); + const candidate = await createCandidateViaAPI(app, thread.id, { + name: "Winner", + categoryId: 1, + priceCents: 30000, + }); + + const res = await app.request(`/api/threads/${thread.id}/resolve`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ candidateId: candidate.id }), + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.item).toBeDefined(); + expect(body.item.name).toBe("Winner"); + expect(body.item.priceCents).toBe(30000); + }); + + it("on already-resolved thread returns 400", async () => { + const thread = await createThreadViaAPI(app, "Already Resolved"); + const candidate = await createCandidateViaAPI(app, thread.id, { + name: "Winner", + categoryId: 1, + }); + + // Resolve first time + await app.request(`/api/threads/${thread.id}/resolve`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ candidateId: candidate.id }), + }); + + // Try again + const res = await app.request(`/api/threads/${thread.id}/resolve`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ candidateId: candidate.id }), + }); + + expect(res.status).toBe(400); + }); + + it("with wrong candidateId returns 400", async () => { + const t1 = await createThreadViaAPI(app, "Thread 1"); + const t2 = await createThreadViaAPI(app, "Thread 2"); + const candidate = await createCandidateViaAPI(app, t2.id, { + name: "Wrong Thread", + categoryId: 1, + }); + + const res = await app.request(`/api/threads/${t1.id}/resolve`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ candidateId: candidate.id }), + }); + + expect(res.status).toBe(400); + }); + }); +});