feat(26-02): discovery HTTP routes, server registration, and route tests

- Create src/server/routes/discovery.ts with GET /setups, /items, /categories handlers
- Register discoveryRoutes in src/server/index.ts with browseTier rate limiting
- Add auth skip for /api/discovery/* GET requests in auth middleware
- Create tests/routes/discovery.test.ts with 10 tests covering all endpoints and pagination
This commit is contained in:
2026-04-10 14:57:35 +02:00
parent a00b90d97a
commit 0323e0cd33
3 changed files with 287 additions and 0 deletions

View File

@@ -0,0 +1,240 @@
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<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 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<number> {
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);
});
});
});