import { beforeEach, describe, expect, it } from "bun:test"; import { Hono } from "hono"; import { globalItems, items, setups, setupItems } from "../../src/db/schema.ts"; import { discoveryRoutes } from "../../src/server/routes/discovery.ts"; import { createTestDb } from "../helpers/db.ts"; type TestDb = Awaited>; async function createTestApp() { const { db, userId } = await createTestDb(); const app = new Hono(); app.use("*", async (c, next) => { c.set("db", db); // Note: NO userId set — discovery endpoints don't need auth await next(); }); app.route("/api/discovery", discoveryRoutes); return { app, db, userId }; } async function insertGlobalItem( db: TestDb["db"], brand: string, model: string, category?: string, ) { const [row] = await db .insert(globalItems) .values({ brand, model, category: category ?? "bags" }) .returning(); return row; } async function insertPublicSetup( db: TestDb["db"], userId: number, name: string, ) { const [row] = await db .insert(setups) .values({ name, userId, isPublic: true }) .returning(); return row; } async function insertItem( db: TestDb["db"], userId: number, name: string, ): Promise { const [row] = await db .insert(items) .values({ name, categoryId: 1, userId }) .returning(); return row.id; } async function addItemToSetup( db: TestDb["db"], setupId: number, itemId: number, ) { await db.insert(setupItems).values({ setupId, itemId }); } describe("Discovery Routes", () => { let app: Hono; let db: TestDb["db"]; let userId: number; beforeEach(async () => { const testApp = await createTestApp(); app = testApp.app; db = testApp.db; userId = testApp.userId; }); describe("GET /api/discovery/setups", () => { it("returns 200 with { items, nextCursor, hasMore } shape", async () => { await insertPublicSetup(db, userId, "My Bikepacking Setup"); const res = await app.request("/api/discovery/setups"); expect(res.status).toBe(200); const body = await res.json(); expect(body).toHaveProperty("items"); expect(body).toHaveProperty("nextCursor"); expect(body).toHaveProperty("hasMore"); expect(Array.isArray(body.items)).toBe(true); }); it("returns only public setups", async () => { await insertPublicSetup(db, userId, "Public Setup"); // Insert a private setup await db .insert(setups) .values({ name: "Private Setup", userId, isPublic: false }); const res = await app.request("/api/discovery/setups"); expect(res.status).toBe(200); const body = await res.json(); expect(body.items).toHaveLength(1); expect(body.items[0].name).toBe("Public Setup"); }); it("respects limit query param", async () => { for (let i = 0; i < 5; i++) { await insertPublicSetup(db, userId, `Setup ${i}`); } const res = await app.request("/api/discovery/setups?limit=2"); expect(res.status).toBe(200); const body = await res.json(); expect(body.items).toHaveLength(2); expect(body.hasMore).toBe(true); }); }); describe("GET /api/discovery/items", () => { it("returns 200 with { items, nextCursor, hasMore } shape", async () => { await insertGlobalItem(db, "MSR", "PocketRocket 2"); const res = await app.request("/api/discovery/items"); expect(res.status).toBe(200); const body = await res.json(); expect(body).toHaveProperty("items"); expect(body).toHaveProperty("nextCursor"); expect(body).toHaveProperty("hasMore"); expect(Array.isArray(body.items)).toBe(true); }); it("returns 200 with empty items when catalog is empty", async () => { const res = await app.request("/api/discovery/items"); expect(res.status).toBe(200); const body = await res.json(); expect(body.items).toHaveLength(0); expect(body.hasMore).toBe(false); expect(body.nextCursor).toBeNull(); }); it("respects limit query param", async () => { for (let i = 0; i < 5; i++) { await insertGlobalItem(db, `Brand${i}`, `Model${i}`); } const res = await app.request("/api/discovery/items?limit=2"); expect(res.status).toBe(200); const body = await res.json(); expect(body.items).toHaveLength(2); expect(body.hasMore).toBe(true); }); it("pagination with cursor returns items before cursor timestamp", async () => { // Insert items with explicit timestamps so they are definitively ordered const olderTime = new Date("2024-01-01T00:00:00Z"); const newerTime = new Date("2024-06-01T00:00:00Z"); await db.insert(globalItems).values({ brand: "Brand A", model: "Model A", category: "bags", createdAt: olderTime, }); await db.insert(globalItems).values({ brand: "Brand B", model: "Model B", category: "bags", createdAt: newerTime, }); // First page (newest first: Brand B) const res1 = await app.request("/api/discovery/items?limit=1"); expect(res1.status).toBe(200); const page1 = await res1.json(); expect(page1.items).toHaveLength(1); expect(page1.items[0].brand).toBe("Brand B"); expect(page1.hasMore).toBe(true); expect(page1.nextCursor).not.toBeNull(); // Second page using cursor (should return Brand A) const cursor = encodeURIComponent(page1.nextCursor); const res2 = await app.request( `/api/discovery/items?limit=1&cursor=${cursor}`, ); expect(res2.status).toBe(200); const page2 = await res2.json(); expect(page2.items).toHaveLength(1); expect(page2.items[0].brand).toBe("Brand A"); }); }); describe("GET /api/discovery/categories", () => { it("returns 200 with array shape", async () => { await insertGlobalItem(db, "MSR", "PocketRocket", "cooking"); await insertGlobalItem(db, "Revelate", "Terrapin", "bags"); const res = await app.request("/api/discovery/categories"); expect(res.status).toBe(200); const body = await res.json(); expect(Array.isArray(body)).toBe(true); }); it("returns categories with name and itemCount fields", async () => { await insertGlobalItem(db, "MSR", "PocketRocket", "cooking"); await insertGlobalItem(db, "Jetboil", "Flash", "cooking"); await insertGlobalItem(db, "Revelate", "Terrapin", "bags"); const res = await app.request("/api/discovery/categories"); expect(res.status).toBe(200); const body = await res.json(); expect(body.length).toBeGreaterThan(0); expect(body[0]).toHaveProperty("name"); expect(body[0]).toHaveProperty("itemCount"); // cooking has most items (2), should be first expect(body[0].name).toBe("cooking"); expect(body[0].itemCount).toBe(2); }); it("respects limit query param", async () => { await insertGlobalItem(db, "Brand A", "Model A", "category-a"); await insertGlobalItem(db, "Brand B", "Model B", "category-b"); await insertGlobalItem(db, "Brand C", "Model C", "category-c"); const res = await app.request("/api/discovery/categories?limit=2"); expect(res.status).toBe(200); const body = await res.json(); expect(body).toHaveLength(2); }); }); });