Files
GearBox/tests/routes/discovery.test.ts

234 lines
7.0 KiB
TypeScript

import { beforeEach, describe, expect, it } from "bun:test";
import { Hono } from "hono";
import { globalItems, manufacturers, setups } from "../../src/db/schema.ts";
import { discoveryRoutes } from "../../src/server/routes/discovery.ts";
import { createTestDb } from "../helpers/db.ts";
type TestDb = Awaited<ReturnType<typeof createTestDb>>;
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 insertManufacturer(db: TestDb["db"], name: string) {
const slug = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
const [row] = await db
.insert(manufacturers)
.values({ name, slug, website: `https://${slug}.com` })
.onConflictDoUpdate({ target: manufacturers.slug, set: { name } })
.returning();
return row!;
}
async function insertGlobalItem(
db: TestDb["db"],
brand: string,
model: string,
category?: string,
) {
const m = await insertManufacturer(db, brand);
const [row] = await db
.insert(globalItems)
.values({ manufacturerId: m.id, 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, visibility: "public" })
.returning();
return row;
}
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, visibility: "private" });
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");
const mA = await insertManufacturer(db, "Brand A");
const mB = await insertManufacturer(db, "Brand B");
await db.insert(globalItems).values({
manufacturerId: mA.id,
model: "Model A",
category: "bags",
createdAt: olderTime,
});
await db.insert(globalItems).values({
manufacturerId: mB.id,
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);
});
});
});