From 06b6e935f2adcb0932ef9b0e37c4b4c3319bda6c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 10 Apr 2026 14:53:09 +0200 Subject: [PATCH] test(26-01): add failing tests for discovery service - getPopularSetups: ordering, privacy filter, cursor pagination, creatorName - getRecentGlobalItems: ordering, cursor pagination, second page deduplication - getTrendingCategories: ordering by count desc, null category exclusion, empty state --- tests/services/discovery.service.test.ts | 246 +++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 tests/services/discovery.service.test.ts diff --git a/tests/services/discovery.service.test.ts b/tests/services/discovery.service.test.ts new file mode 100644 index 0000000..767ce0c --- /dev/null +++ b/tests/services/discovery.service.test.ts @@ -0,0 +1,246 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { eq } from "drizzle-orm"; +import { + globalItems, + items, + setups, + setupItems, + 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, isPublic: true }) + .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, isPublic: false }) + .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); + }); + }); +});