305 lines
8.9 KiB
TypeScript
305 lines
8.9 KiB
TypeScript
import { beforeEach, describe, expect, it } from "bun:test";
|
|
import { eq } from "drizzle-orm";
|
|
import {
|
|
globalItems,
|
|
items,
|
|
manufacturers,
|
|
setupItems,
|
|
setups,
|
|
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<ReturnType<typeof createTestDb>>;
|
|
|
|
async function insertManufacturer(db: TestDb["db"], name: string) {
|
|
const slug = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
|
|
const [existing] = await db
|
|
.select()
|
|
.from(manufacturers)
|
|
.where(eq(manufacturers.slug, slug));
|
|
if (existing) return existing;
|
|
const [row] = await db
|
|
.insert(manufacturers)
|
|
.values({ name, slug, website: `https://${slug}.com` })
|
|
.returning();
|
|
return row!;
|
|
}
|
|
|
|
async function insertGlobalItem(
|
|
db: TestDb["db"],
|
|
data: { brand: string; model: string; category?: string },
|
|
) {
|
|
const m = await insertManufacturer(db, data.brand);
|
|
const [row] = await db
|
|
.insert(globalItems)
|
|
.values({
|
|
manufacturerId: m.id,
|
|
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, visibility: "public" })
|
|
.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, visibility: "private" })
|
|
.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);
|
|
});
|
|
});
|
|
});
|