diff --git a/src/db/schema.ts b/src/db/schema.ts index 6d1cc3e..b51bdb2 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -28,6 +28,41 @@ export const items = sqliteTable("items", { .$defaultFn(() => new Date()), }); +export const threads = sqliteTable("threads", { + id: integer("id").primaryKey({ autoIncrement: true }), + name: text("name").notNull(), + status: text("status").notNull().default("active"), + resolvedCandidateId: integer("resolved_candidate_id"), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), + updatedAt: integer("updated_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), +}); + +export const threadCandidates = sqliteTable("thread_candidates", { + id: integer("id").primaryKey({ autoIncrement: true }), + threadId: integer("thread_id") + .notNull() + .references(() => threads.id, { onDelete: "cascade" }), + name: text("name").notNull(), + weightGrams: real("weight_grams"), + priceCents: integer("price_cents"), + categoryId: integer("category_id") + .notNull() + .references(() => categories.id), + notes: text("notes"), + productUrl: text("product_url"), + imageFilename: text("image_filename"), + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), + updatedAt: integer("updated_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), +}); + 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 b1f2a82..eb2e2dc 100644 --- a/src/shared/schemas.ts +++ b/src/shared/schemas.ts @@ -23,3 +23,28 @@ export const updateCategorySchema = z.object({ name: z.string().min(1).optional(), emoji: z.string().min(1).max(4).optional(), }); + +// Thread schemas +export const createThreadSchema = z.object({ + name: z.string().min(1, "Thread name is required"), +}); + +export const updateThreadSchema = z.object({ + name: z.string().min(1).optional(), +}); + +// Candidate schemas (same fields as items) +export const createCandidateSchema = z.object({ + name: z.string().min(1, "Name is required"), + weightGrams: z.number().nonnegative().optional(), + priceCents: z.number().int().nonnegative().optional(), + categoryId: z.number().int().positive(), + notes: z.string().optional(), + productUrl: z.string().url().optional().or(z.literal("")), +}); + +export const updateCandidateSchema = createCandidateSchema.partial(); + +export const resolveThreadSchema = z.object({ + candidateId: z.number().int().positive(), +}); diff --git a/src/shared/types.ts b/src/shared/types.ts index 91fe285..685fd6e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -4,15 +4,27 @@ import type { updateItemSchema, createCategorySchema, updateCategorySchema, + createThreadSchema, + updateThreadSchema, + createCandidateSchema, + updateCandidateSchema, + resolveThreadSchema, } from "./schemas.ts"; -import type { items, categories } from "../db/schema.ts"; +import type { items, categories, threads, threadCandidates } from "../db/schema.ts"; // Types inferred from Zod schemas export type CreateItem = z.infer; export type UpdateItem = z.infer; export type CreateCategory = z.infer; export type UpdateCategory = z.infer; +export type CreateThread = z.infer; +export type UpdateThread = z.infer; +export type CreateCandidate = z.infer; +export type UpdateCandidate = z.infer; +export type ResolveThread = 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; diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts index afb57a8..c7725f7 100644 --- a/tests/helpers/db.ts +++ b/tests/helpers/db.ts @@ -31,6 +31,33 @@ export function createTestDb() { ) `); + sqlite.run(` + CREATE TABLE threads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + resolved_candidate_id INTEGER, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) + ) + `); + + sqlite.run(` + CREATE TABLE thread_candidates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE, + name TEXT NOT NULL, + weight_grams REAL, + price_cents INTEGER, + category_id INTEGER NOT NULL REFERENCES categories(id), + notes TEXT, + product_url TEXT, + image_filename TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) + ) + `); + sqlite.run(` CREATE TABLE settings ( key TEXT PRIMARY KEY, diff --git a/tests/services/thread.service.test.ts b/tests/services/thread.service.test.ts new file mode 100644 index 0000000..6a19c6e --- /dev/null +++ b/tests/services/thread.service.test.ts @@ -0,0 +1,280 @@ +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" }); + + 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" }); + 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" }); + const t2 = createThread(db, { name: "Resolved Thread" }); + 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" }); + const t2 = createThread(db, { name: "Resolved Thread" }); + 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" }); + 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].categoryEmoji).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" }); + 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" }); + 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" }); + 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" }); + 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" }); + 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" }); + 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" }); + 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" }); + const thread2 = createThread(db, { name: "Thread 2" }); + 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" }); + const result = resolveThread(db, thread.id, 9999); + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + }); +});