test(26-01): add failing tests for discovery service
- getPopularSetups: ordering, privacy filter, cursor pagination, creatorName - getRecentGlobalItems: ordering, cursor pagination, second page deduplication - getTrendingCategories: ordering by count desc, null category exclusion, empty state
This commit is contained in:
246
tests/services/discovery.service.test.ts
Normal file
246
tests/services/discovery.service.test.ts
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
globalItems,
|
||||||
|
items,
|
||||||
|
setups,
|
||||||
|
setupItems,
|
||||||
|
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 insertGlobalItem(
|
||||||
|
db: TestDb["db"],
|
||||||
|
data: { brand: string; model: string; category?: string },
|
||||||
|
) {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(globalItems)
|
||||||
|
.values({
|
||||||
|
brand: data.brand,
|
||||||
|
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, isPublic: true })
|
||||||
|
.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, isPublic: false })
|
||||||
|
.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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user