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, categoryId = 1) { const res = await app.request("/api/threads", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, categoryId }), }); 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", categoryId: 1 }), }); 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); }); }); });