import { beforeEach, describe, expect, it } from "bun:test"; import { eq } from "drizzle-orm"; import { globalItems, items, setupItems, setups, users, } from "../../src/db/schema.ts"; import { getPopularSetups, getRecentGlobalItems, getTrendingCategories, } from "../../src/server/services/discovery.service.ts"; import { createTestDb } from "../helpers/db.ts"; type TestDb = Awaited>; async function insertGlobalItem( db: TestDb["db"], data: { brand: string; model: string; category?: string }, ) { const [row] = await db .insert(globalItems) .values({ brand: data.brand, model: data.model, category: data.category ?? null, }) .returning(); return row; } async function insertItem(db: TestDb["db"], userId: number, categoryId = 1) { const [row] = await db .insert(items) .values({ name: "Test Item", categoryId, userId }) .returning(); return row; } async function insertPublicSetup( db: TestDb["db"], userId: number, name: string, itemIds: number[], ) { const [setup] = await db .insert(setups) .values({ name, userId, visibility: "public" }) .returning(); for (const itemId of itemIds) { await db.insert(setupItems).values({ setupId: setup.id, itemId }); } return setup; } async function insertPrivateSetup( db: TestDb["db"], userId: number, name: string, ) { const [setup] = await db .insert(setups) .values({ name, userId, visibility: "private" }) .returning(); return setup; } describe("Discovery Service", () => { let db: TestDb["db"]; let userId: number; beforeEach(async () => { const testDb = await createTestDb(); db = testDb.db; userId = testDb.userId; }); describe("getPopularSetups", () => { it("returns public setups ordered by item count descending", async () => { const item1 = await insertItem(db, userId); const item2 = await insertItem(db, userId); const item3 = await insertItem(db, userId); // Setup with 1 item await insertPublicSetup(db, userId, "Solo Setup", [item1.id]); // Setup with 2 items await insertPublicSetup(db, userId, "Dual Setup", [item2.id, item3.id]); const result = await getPopularSetups(db); expect(result.items).toHaveLength(2); expect(result.items[0].name).toBe("Dual Setup"); expect(result.items[0].itemCount).toBe(2); expect(result.items[1].name).toBe("Solo Setup"); expect(result.items[1].itemCount).toBe(1); }); it("excludes private setups", async () => { const item1 = await insertItem(db, userId); await insertPublicSetup(db, userId, "Public Setup", [item1.id]); await insertPrivateSetup(db, userId, "Private Setup"); const result = await getPopularSetups(db); expect(result.items).toHaveLength(1); expect(result.items[0].name).toBe("Public Setup"); }); it("returns hasMore=true and nextCursor when more results exist", async () => { const item1 = await insertItem(db, userId); const item2 = await insertItem(db, userId); const item3 = await insertItem(db, userId); await insertPublicSetup(db, userId, "Setup A", [ item1.id, item2.id, item3.id, ]); await insertPublicSetup(db, userId, "Setup B", [item1.id, item2.id]); await insertPublicSetup(db, userId, "Setup C", [item1.id]); const result = await getPopularSetups(db, 1); expect(result.hasMore).toBe(true); expect(result.nextCursor).not.toBeNull(); }); it("returns second page without duplicates when cursor provided", async () => { const item1 = await insertItem(db, userId); const item2 = await insertItem(db, userId); const item3 = await insertItem(db, userId); await insertPublicSetup(db, userId, "Setup A", [ item1.id, item2.id, item3.id, ]); await insertPublicSetup(db, userId, "Setup B", [item1.id, item2.id]); await insertPublicSetup(db, userId, "Setup C", [item1.id]); const page1 = await getPopularSetups(db, 1); expect(page1.items).toHaveLength(1); expect(page1.nextCursor).not.toBeNull(); const page2 = await getPopularSetups(db, 1, page1.nextCursor!); expect(page2.items).toHaveLength(1); expect(page2.items[0].id).not.toBe(page1.items[0].id); }); it("includes creatorName from users.displayName", async () => { // Update user display name await db .update(users) .set({ displayName: "Jean-Luc" }) .where(eq(users.id, userId)); const item1 = await insertItem(db, userId); await insertPublicSetup(db, userId, "My Setup", [item1.id]); const result = await getPopularSetups(db); expect(result.items).toHaveLength(1); expect(result.items[0].creatorName).toBe("Jean-Luc"); }); }); describe("getRecentGlobalItems", () => { it("returns items ordered by createdAt descending", async () => { // Insert items with slight delay to get different timestamps const item1 = await insertGlobalItem(db, { brand: "BrandA", model: "Model1", }); await new Promise((r) => setTimeout(r, 5)); const item2 = await insertGlobalItem(db, { brand: "BrandB", model: "Model2", }); await new Promise((r) => setTimeout(r, 5)); const item3 = await insertGlobalItem(db, { brand: "BrandC", model: "Model3", }); const result = await getRecentGlobalItems(db); expect(result.items).toHaveLength(3); // Most recent first expect(result.items[0].id).toBe(item3.id); expect(result.items[1].id).toBe(item2.id); expect(result.items[2].id).toBe(item1.id); }); it("returns hasMore=true and nextCursor when more results exist", async () => { await insertGlobalItem(db, { brand: "BrandA", model: "Model1" }); await new Promise((r) => setTimeout(r, 5)); await insertGlobalItem(db, { brand: "BrandB", model: "Model2" }); await new Promise((r) => setTimeout(r, 5)); await insertGlobalItem(db, { brand: "BrandC", model: "Model3" }); const result = await getRecentGlobalItems(db, 2); expect(result.hasMore).toBe(true); expect(result.nextCursor).not.toBeNull(); }); it("returns second page without duplicates when cursor provided", async () => { await insertGlobalItem(db, { brand: "BrandA", model: "Model1" }); await new Promise((r) => setTimeout(r, 5)); await insertGlobalItem(db, { brand: "BrandB", model: "Model2" }); await new Promise((r) => setTimeout(r, 5)); await insertGlobalItem(db, { brand: "BrandC", model: "Model3" }); const page1 = await getRecentGlobalItems(db, 2); expect(page1.items).toHaveLength(2); expect(page1.nextCursor).not.toBeNull(); const page2 = await getRecentGlobalItems(db, 2, page1.nextCursor!); expect(page2.items).toHaveLength(1); // Page 2 item should not appear in page 1 const page1Ids = page1.items.map((i) => i.id); expect(page1Ids).not.toContain(page2.items[0].id); }); }); describe("getTrendingCategories", () => { it("returns categories ordered by item count descending", async () => { // 3 items in Tents, 1 in Bags, 2 in Stoves await insertGlobalItem(db, { brand: "BrandA", model: "Tent1", category: "Tents", }); await insertGlobalItem(db, { brand: "BrandB", model: "Tent2", category: "Tents", }); await insertGlobalItem(db, { brand: "BrandC", model: "Tent3", category: "Tents", }); await insertGlobalItem(db, { brand: "BrandD", model: "Bag1", category: "Bags", }); await insertGlobalItem(db, { brand: "BrandE", model: "Stove1", category: "Stoves", }); await insertGlobalItem(db, { brand: "BrandF", model: "Stove2", category: "Stoves", }); const result = await getTrendingCategories(db); expect(result).toHaveLength(3); expect(result[0].name).toBe("Tents"); expect(result[0].itemCount).toBe(3); expect(result[1].name).toBe("Stoves"); expect(result[1].itemCount).toBe(2); expect(result[2].name).toBe("Bags"); expect(result[2].itemCount).toBe(1); }); it("excludes items with null category", async () => { await insertGlobalItem(db, { brand: "BrandA", model: "Tent1", category: "Tents", }); // No category — should be excluded await insertGlobalItem(db, { brand: "BrandB", model: "NoCategory" }); const result = await getTrendingCategories(db); expect(result).toHaveLength(1); expect(result[0].name).toBe("Tents"); }); it("returns empty array when no items have a category set", async () => { await insertGlobalItem(db, { brand: "BrandA", model: "Model1" }); await insertGlobalItem(db, { brand: "BrandB", model: "Model2" }); const result = await getTrendingCategories(db); expect(result).toHaveLength(0); }); }); });