import { describe, it, expect, beforeEach } from "bun:test"; import { createTestDb } from "../helpers/db.ts"; import { createThread, getAllThreads, getThreadWithCandidates, createCandidate, updateCandidate, deleteCandidate, updateThread, deleteThread, resolveThread, } from "../../src/server/services/thread.service.ts"; import { createItem } from "../../src/server/services/item.service.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(); }); }); 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"); }); }); 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(); }); }); 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("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(); }); }); });