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