234 lines
7.0 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|