import { beforeEach, describe, expect, it } from "bun:test"; import { createCandidate, createThread, deleteCandidate, deleteThread, getAllThreads, getThreadWithCandidates, reorderCandidates, resolveThread, updateCandidate, updateThread, } from "../../src/server/services/thread.service.ts"; import { createTestDb } from "../helpers/db.ts"; describe("Thread Service", () => { let db: ReturnType; beforeEach(() => { db = createTestDb(); }); describe("createThread", () => { it("creates thread with name, returns thread with id/status/timestamps", () => { const thread = createThread(db, { name: "New Tent", categoryId: 1 }); expect(thread).toBeDefined(); expect(thread.id).toBeGreaterThan(0); expect(thread.name).toBe("New Tent"); expect(thread.status).toBe("active"); expect(thread.resolvedCandidateId).toBeNull(); expect(thread.createdAt).toBeDefined(); expect(thread.updatedAt).toBeDefined(); }); }); describe("getAllThreads", () => { it("returns active threads with candidateCount and price range", () => { const thread = createThread(db, { name: "Backpack Options", categoryId: 1, }); createCandidate(db, thread.id, { name: "Pack A", categoryId: 1, priceCents: 20000, }); createCandidate(db, thread.id, { name: "Pack B", categoryId: 1, priceCents: 35000, }); const threads = getAllThreads(db); expect(threads).toHaveLength(1); expect(threads[0].name).toBe("Backpack Options"); expect(threads[0].candidateCount).toBe(2); expect(threads[0].minPriceCents).toBe(20000); expect(threads[0].maxPriceCents).toBe(35000); }); it("excludes resolved threads by default", () => { const _t1 = createThread(db, { name: "Active Thread", categoryId: 1 }); const t2 = createThread(db, { name: "Resolved Thread", categoryId: 1 }); const candidate = createCandidate(db, t2.id, { name: "Winner", categoryId: 1, }); resolveThread(db, t2.id, candidate.id); const active = getAllThreads(db); expect(active).toHaveLength(1); expect(active[0].name).toBe("Active Thread"); }); it("includes resolved threads when includeResolved=true", () => { const _t1 = createThread(db, { name: "Active Thread", categoryId: 1 }); const t2 = createThread(db, { name: "Resolved Thread", categoryId: 1 }); const candidate = createCandidate(db, t2.id, { name: "Winner", categoryId: 1, }); resolveThread(db, t2.id, candidate.id); const all = getAllThreads(db, true); expect(all).toHaveLength(2); }); }); describe("getThreadWithCandidates", () => { it("returns thread with nested candidates array including category info", () => { const thread = createThread(db, { name: "Tent Options", categoryId: 1 }); createCandidate(db, thread.id, { name: "Tent A", categoryId: 1, weightGrams: 1200, priceCents: 30000, }); const result = getThreadWithCandidates(db, thread.id); expect(result).toBeDefined(); expect(result?.name).toBe("Tent Options"); expect(result?.candidates).toHaveLength(1); expect(result?.candidates[0].name).toBe("Tent A"); expect(result?.candidates[0].categoryName).toBe("Uncategorized"); expect(result?.candidates[0].categoryIcon).toBeDefined(); }); it("returns null for non-existent thread", () => { const result = getThreadWithCandidates(db, 9999); expect(result).toBeNull(); }); it("includes pros and cons on each candidate", () => { const thread = createThread(db, { name: "Tent Options", categoryId: 1 }); createCandidate(db, thread.id, { name: "Tent A", categoryId: 1, pros: "Lightweight", cons: "Pricey", }); const result = getThreadWithCandidates(db, thread.id); expect(result).toBeDefined(); expect(result?.candidates[0].pros).toBe("Lightweight"); expect(result?.candidates[0].cons).toBe("Pricey"); }); }); describe("createCandidate", () => { it("adds candidate to thread with all item-compatible fields", () => { const thread = createThread(db, { name: "Tent Options", categoryId: 1 }); const candidate = createCandidate(db, thread.id, { name: "Tent A", weightGrams: 1200, priceCents: 30000, categoryId: 1, notes: "Ultralight 2-person", productUrl: "https://example.com/tent", }); expect(candidate).toBeDefined(); expect(candidate.id).toBeGreaterThan(0); expect(candidate.threadId).toBe(thread.id); expect(candidate.name).toBe("Tent A"); expect(candidate.weightGrams).toBe(1200); expect(candidate.priceCents).toBe(30000); expect(candidate.categoryId).toBe(1); expect(candidate.notes).toBe("Ultralight 2-person"); expect(candidate.productUrl).toBe("https://example.com/tent"); }); it("stores and returns pros and cons", () => { const thread = createThread(db, { name: "Tent Options", categoryId: 1 }); const candidate = createCandidate(db, thread.id, { name: "Tent A", categoryId: 1, pros: "Lightweight\nGood reviews", cons: "Expensive", }); expect(candidate.pros).toBe("Lightweight\nGood reviews"); expect(candidate.cons).toBe("Expensive"); }); it("returns null for pros and cons when not provided", () => { const thread = createThread(db, { name: "Tent Options", categoryId: 1 }); const candidate = createCandidate(db, thread.id, { name: "Tent B", categoryId: 1, }); expect(candidate.pros).toBeNull(); expect(candidate.cons).toBeNull(); }); }); describe("updateCandidate", () => { it("updates candidate fields, returns updated candidate", () => { const thread = createThread(db, { name: "Test", categoryId: 1 }); const candidate = createCandidate(db, thread.id, { name: "Original", categoryId: 1, }); const updated = updateCandidate(db, candidate.id, { name: "Updated Name", priceCents: 15000, }); expect(updated).toBeDefined(); expect(updated?.name).toBe("Updated Name"); expect(updated?.priceCents).toBe(15000); }); it("returns null for non-existent candidate", () => { const result = updateCandidate(db, 9999, { name: "Ghost" }); expect(result).toBeNull(); }); it("can set and clear pros and cons", () => { const thread = createThread(db, { name: "Test", categoryId: 1 }); const candidate = createCandidate(db, thread.id, { name: "Original", categoryId: 1, }); // Set pros and cons const withPros = updateCandidate(db, candidate.id, { pros: "Lightweight", cons: "Expensive", }); expect(withPros?.pros).toBe("Lightweight"); expect(withPros?.cons).toBe("Expensive"); // Clear pros and cons by setting to empty string const cleared = updateCandidate(db, candidate.id, { pros: "", cons: "", }); // Empty string stored as-is or null — either is acceptable expect(cleared?.pros == null || cleared?.pros === "").toBe(true); expect(cleared?.cons == null || cleared?.cons === "").toBe(true); }); }); describe("deleteCandidate", () => { it("removes candidate, returns deleted candidate", () => { const thread = createThread(db, { name: "Test", categoryId: 1 }); const candidate = createCandidate(db, thread.id, { name: "To Delete", categoryId: 1, }); const deleted = deleteCandidate(db, candidate.id); expect(deleted).toBeDefined(); expect(deleted?.name).toBe("To Delete"); // Verify it's gone const result = getThreadWithCandidates(db, thread.id); expect(result?.candidates).toHaveLength(0); }); it("returns null for non-existent candidate", () => { const result = deleteCandidate(db, 9999); expect(result).toBeNull(); }); }); describe("updateThread", () => { it("updates thread name", () => { const thread = createThread(db, { name: "Original", categoryId: 1 }); const updated = updateThread(db, thread.id, { name: "Renamed" }); expect(updated).toBeDefined(); expect(updated?.name).toBe("Renamed"); }); it("returns null for non-existent thread", () => { const result = updateThread(db, 9999, { name: "Ghost" }); expect(result).toBeNull(); }); }); describe("deleteThread", () => { it("removes thread and cascading candidates", () => { const thread = createThread(db, { name: "To Delete", categoryId: 1 }); createCandidate(db, thread.id, { name: "Candidate", categoryId: 1 }); const deleted = deleteThread(db, thread.id); expect(deleted).toBeDefined(); expect(deleted?.name).toBe("To Delete"); // Thread and candidates gone const result = getThreadWithCandidates(db, thread.id); expect(result).toBeNull(); }); it("returns null for non-existent thread", () => { const result = deleteThread(db, 9999); expect(result).toBeNull(); }); }); describe("candidate status", () => { it("createCandidate without status returns a candidate with status 'researching'", () => { const thread = createThread(db, { name: "Test Thread", categoryId: 1 }); const candidate = createCandidate(db, thread.id, { name: "No Status", categoryId: 1, }); expect(candidate.status).toBe("researching"); }); it("createCandidate with status 'ordered' returns a candidate with status 'ordered'", () => { const thread = createThread(db, { name: "Test Thread", categoryId: 1 }); const candidate = createCandidate(db, thread.id, { name: "Ordered Item", categoryId: 1, status: "ordered", }); expect(candidate.status).toBe("ordered"); }); it("updateCandidate can change status from 'researching' to 'ordered'", () => { const thread = createThread(db, { name: "Test Thread", categoryId: 1 }); const candidate = createCandidate(db, thread.id, { name: "Status Change", categoryId: 1, }); expect(candidate.status).toBe("researching"); const updated = updateCandidate(db, candidate.id, { status: "ordered", }); expect(updated?.status).toBe("ordered"); }); it("updateCandidate can change status from 'ordered' to 'arrived'", () => { const thread = createThread(db, { name: "Test Thread", categoryId: 1 }); const candidate = createCandidate(db, thread.id, { name: "Arriving Item", categoryId: 1, status: "ordered", }); const updated = updateCandidate(db, candidate.id, { status: "arrived", }); expect(updated?.status).toBe("arrived"); }); it("getThreadWithCandidates includes status field on each candidate", () => { const thread = createThread(db, { name: "Status Thread", categoryId: 1 }); createCandidate(db, thread.id, { name: "Candidate A", categoryId: 1, }); createCandidate(db, thread.id, { name: "Candidate B", categoryId: 1, status: "ordered", }); const result = getThreadWithCandidates(db, thread.id); expect(result).toBeDefined(); expect(result?.candidates).toHaveLength(2); const candidateA = result?.candidates.find( (c) => c.name === "Candidate A", ); const candidateB = result?.candidates.find( (c) => c.name === "Candidate B", ); expect(candidateA?.status).toBe("researching"); expect(candidateB?.status).toBe("ordered"); }); }); describe("sort_order ordering", () => { it("getThreadWithCandidates returns candidates ordered by sort_order ascending", () => { const thread = createThread(db, { name: "Order Test", categoryId: 1 }); const c1 = createCandidate(db, thread.id, { name: "Candidate 1", categoryId: 1, }); const c2 = createCandidate(db, thread.id, { name: "Candidate 2", categoryId: 1, }); const c3 = createCandidate(db, thread.id, { name: "Candidate 3", categoryId: 1, }); // Manually set sort_orders out of creation order using reorderCandidates reorderCandidates(db, thread.id, [c3.id, c1.id, c2.id]); const result = getThreadWithCandidates(db, thread.id); expect(result).toBeDefined(); expect(result?.candidates[0].id).toBe(c3.id); expect(result?.candidates[1].id).toBe(c1.id); expect(result?.candidates[2].id).toBe(c2.id); }); it("createCandidate assigns sort_order = max existing sort_order + 1000", () => { const thread = createThread(db, { name: "Append Test", categoryId: 1 }); // First candidate should get sort_order 1000 const c1 = createCandidate(db, thread.id, { name: "First", categoryId: 1, }); expect(c1.sortOrder).toBe(1000); // Second candidate should get sort_order 2000 const c2 = createCandidate(db, thread.id, { name: "Second", categoryId: 1, }); expect(c2.sortOrder).toBe(2000); }); }); describe("reorderCandidates", () => { it("reorderCandidates updates sort_order so querying returns candidates in new order", () => { const thread = createThread(db, { name: "Reorder Test", categoryId: 1 }); const c1 = createCandidate(db, thread.id, { name: "Candidate 1", categoryId: 1, }); const c2 = createCandidate(db, thread.id, { name: "Candidate 2", categoryId: 1, }); const c3 = createCandidate(db, thread.id, { name: "Candidate 3", categoryId: 1, }); const result = reorderCandidates(db, thread.id, [c3.id, c1.id, c2.id]); expect(result.success).toBe(true); const fetched = getThreadWithCandidates(db, thread.id); expect(fetched?.candidates[0].id).toBe(c3.id); expect(fetched?.candidates[1].id).toBe(c1.id); expect(fetched?.candidates[2].id).toBe(c2.id); }); it("returns { success: false, error } when thread status is 'resolved'", () => { const thread = createThread(db, { name: "Resolved Thread", categoryId: 1 }); const candidate = createCandidate(db, thread.id, { name: "Winner", categoryId: 1, }); resolveThread(db, thread.id, candidate.id); const result = reorderCandidates(db, thread.id, [candidate.id]); expect(result.success).toBe(false); expect(result.error).toBeDefined(); }); it("returns { success: false } when thread does not exist", () => { const result = reorderCandidates(db, 9999, [1, 2]); expect(result.success).toBe(false); }); }); describe("resolveThread", () => { it("atomically creates collection item from candidate data and archives thread", () => { const thread = createThread(db, { name: "Tent Decision", categoryId: 1 }); const candidate = createCandidate(db, thread.id, { name: "Winner Tent", weightGrams: 1200, priceCents: 30000, categoryId: 1, notes: "Best choice", productUrl: "https://example.com/tent", }); const result = resolveThread(db, thread.id, candidate.id); expect(result.success).toBe(true); expect(result.item).toBeDefined(); expect(result.item?.name).toBe("Winner Tent"); expect(result.item?.weightGrams).toBe(1200); expect(result.item?.priceCents).toBe(30000); expect(result.item?.categoryId).toBe(1); expect(result.item?.notes).toBe("Best choice"); expect(result.item?.productUrl).toBe("https://example.com/tent"); // Thread should be resolved const resolved = getThreadWithCandidates(db, thread.id); expect(resolved?.status).toBe("resolved"); expect(resolved?.resolvedCandidateId).toBe(candidate.id); }); it("fails if thread is not active", () => { const thread = createThread(db, { name: "Already Resolved", categoryId: 1, }); const candidate = createCandidate(db, thread.id, { name: "Winner", categoryId: 1, }); resolveThread(db, thread.id, candidate.id); // Try to resolve again const result = resolveThread(db, thread.id, candidate.id); expect(result.success).toBe(false); expect(result.error).toBeDefined(); }); it("fails if candidate is not in thread", () => { const thread1 = createThread(db, { name: "Thread 1", categoryId: 1 }); const thread2 = createThread(db, { name: "Thread 2", categoryId: 1 }); const candidate = createCandidate(db, thread2.id, { name: "Wrong Thread", categoryId: 1, }); const result = resolveThread(db, thread1.id, candidate.id); expect(result.success).toBe(false); expect(result.error).toBeDefined(); }); it("fails if candidate not found", () => { const thread = createThread(db, { name: "Test", categoryId: 1 }); const result = resolveThread(db, thread.id, 9999); expect(result.success).toBe(false); expect(result.error).toBeDefined(); }); }); });