Files
GearBox/tests/services/global-item.service.test.ts
Jean-Luc Makiola db471001fa feat(37-01): admin global item services, routes, and unit tests
- Add listGlobalItemsForAdmin: paginated with batched tag/ownerCount queries
- Add updateGlobalItemById: partial update in transaction, syncs tags
- Add deleteGlobalItem: nullifies FK refs, removes tag associations before delete
- Create src/server/routes/admin-items.ts with GET/GET:id/PUT/DELETE endpoints
- Mount adminItemRoutes at /items in admin.ts (protected by requireAuth+requireAdmin)
- Extend global-item.service.test.ts with 13 new tests (all passing)

Closes ADMN-02, ADMN-03, ADMN-04 (server side)
2026-04-19 21:32:42 +02:00

659 lines
19 KiB
TypeScript

import { beforeEach, describe, expect, it } from "bun:test";
import { eq } from "drizzle-orm";
import * as schema from "../../src/db/schema.ts";
import {
globalItems,
globalItemTags,
items,
tags,
} from "../../src/db/schema.ts";
import { seedGlobalItems } from "../../src/db/seed-global-items.ts";
import {
bulkUpsertGlobalItems,
deleteGlobalItem,
getGlobalItemWithOwnerCount,
listGlobalItemsForAdmin,
searchGlobalItems,
updateGlobalItemById,
upsertGlobalItem,
} from "../../src/server/services/global-item.service.ts";
import { createTestDb } from "../helpers/db.ts";
type TestDb = Awaited<ReturnType<typeof createTestDb>>;
async function insertManufacturer(
db: TestDb["db"],
name = "Apidura",
slug = "apidura",
) {
const [row] = await db
.insert(schema.manufacturers)
.values({ name, slug, website: `https://${slug}.com` })
.returning();
return row!;
}
async function insertGlobalItem(
db: TestDb["db"],
data: {
manufacturerId: number;
model: string;
category?: string;
weightGrams?: number;
priceCents?: number;
},
) {
const [row] = await db
.insert(globalItems)
.values({
manufacturerId: data.manufacturerId,
model: data.model,
category: data.category ?? null,
weightGrams: data.weightGrams ?? null,
priceCents: data.priceCents ?? null,
})
.returning();
return row!;
}
async function insertItem(
db: TestDb["db"],
name: string,
userId: number,
opts?: { globalItemId?: number },
) {
const [row] = await db
.insert(items)
.values({ name, categoryId: 1, userId, globalItemId: opts?.globalItemId })
.returning();
return row;
}
async function insertTag(db: TestDb["db"], name: string) {
const [row] = await db.insert(tags).values({ name }).returning();
return row;
}
async function tagGlobalItem(
db: TestDb["db"],
globalItemId: number,
tagId: number,
) {
await db.insert(globalItemTags).values({ globalItemId, tagId });
}
describe("Global Item Service", () => {
let db: TestDb["db"];
let userId: number;
beforeEach(async () => {
const testDb = await createTestDb();
db = testDb.db;
userId = testDb.userId;
});
describe("searchGlobalItems", () => {
it("returns all global items when no query provided", async () => {
const m1 = await insertManufacturer(
db,
"Revelate Designs",
"revelate-designs",
);
const m2 = await insertManufacturer(db, "Apidura", "apidura");
await insertGlobalItem(db, {
manufacturerId: m1.id,
model: "Terrapin System",
});
await insertGlobalItem(db, {
manufacturerId: m2.id,
model: "Handlebar Pack",
});
const results = await searchGlobalItems(db);
expect(results).toHaveLength(2);
});
it("returns items matching brand (case-insensitive)", async () => {
const m1 = await insertManufacturer(
db,
"Revelate Designs",
"revelate-designs",
);
const m2 = await insertManufacturer(db, "Apidura", "apidura");
await insertGlobalItem(db, {
manufacturerId: m1.id,
model: "Terrapin System",
});
await insertGlobalItem(db, {
manufacturerId: m2.id,
model: "Handlebar Pack",
});
const results = await searchGlobalItems(db, "revelate");
expect(results).toHaveLength(1);
expect(results[0].brand).toBe("Revelate Designs");
});
it("returns items matching model (case-insensitive)", async () => {
const m1 = await insertManufacturer(
db,
"Revelate Designs",
"revelate-designs",
);
const m2 = await insertManufacturer(db, "Apidura", "apidura");
await insertGlobalItem(db, {
manufacturerId: m1.id,
model: "Terrapin System",
});
await insertGlobalItem(db, {
manufacturerId: m2.id,
model: "Handlebar Pack",
});
const results = await searchGlobalItems(db, "HANDLEBAR");
expect(results).toHaveLength(1);
expect(results[0].model).toBe("Handlebar Pack");
});
it("does not match everything with wildcard chars", async () => {
const m1 = await insertManufacturer(
db,
"Revelate Designs",
"revelate-designs",
);
const m2 = await insertManufacturer(db, "Apidura", "apidura");
await insertGlobalItem(db, {
manufacturerId: m1.id,
model: "Terrapin System",
});
await insertGlobalItem(db, {
manufacturerId: m2.id,
model: "Handlebar Pack",
});
const results = await searchGlobalItems(db, "100%");
expect(results).toHaveLength(0);
});
it("returns all items when no tags provided", async () => {
const m1 = await insertManufacturer(
db,
"Revelate Designs",
"revelate-designs",
);
const m2 = await insertManufacturer(db, "Apidura", "apidura");
await insertGlobalItem(db, {
manufacturerId: m1.id,
model: "Terrapin System",
});
await insertGlobalItem(db, {
manufacturerId: m2.id,
model: "Handlebar Pack",
});
const results = await searchGlobalItems(db, undefined, undefined);
expect(results).toHaveLength(2);
});
it("filters by single tag", async () => {
const m1 = await insertManufacturer(
db,
"Revelate Designs",
"revelate-designs",
);
const m2 = await insertManufacturer(db, "Apidura", "apidura");
const gi1 = await insertGlobalItem(db, {
manufacturerId: m1.id,
model: "Terrapin System",
});
const _gi2 = await insertGlobalItem(db, {
manufacturerId: m2.id,
model: "Handlebar Pack",
});
const tag = await insertTag(db, "ultralight");
await tagGlobalItem(db, gi1.id, tag.id);
const results = await searchGlobalItems(db, undefined, ["ultralight"]);
expect(results).toHaveLength(1);
expect(results[0].brand).toBe("Revelate Designs");
});
it("filters by multiple tags with AND logic", async () => {
const m1 = await insertManufacturer(
db,
"Revelate Designs",
"revelate-designs",
);
const m2 = await insertManufacturer(db, "Apidura", "apidura");
const gi1 = await insertGlobalItem(db, {
manufacturerId: m1.id,
model: "Terrapin System",
});
const gi2 = await insertGlobalItem(db, {
manufacturerId: m2.id,
model: "Handlebar Pack",
});
const tagUL = await insertTag(db, "ultralight");
const tagBP = await insertTag(db, "bikepacking");
// gi1 has both tags
await tagGlobalItem(db, gi1.id, tagUL.id);
await tagGlobalItem(db, gi1.id, tagBP.id);
// gi2 has only bikepacking
await tagGlobalItem(db, gi2.id, tagBP.id);
const results = await searchGlobalItems(db, undefined, [
"ultralight",
"bikepacking",
]);
expect(results).toHaveLength(1);
expect(results[0].brand).toBe("Revelate Designs");
});
it("combines text search and tag filtering", async () => {
const m = await insertManufacturer(
db,
"Revelate Designs",
"revelate-designs",
);
const gi1 = await insertGlobalItem(db, {
manufacturerId: m.id,
model: "Terrapin System",
});
const gi2 = await insertGlobalItem(db, {
manufacturerId: m.id,
model: "Spinelock",
});
const tag = await insertTag(db, "bikepacking");
await tagGlobalItem(db, gi1.id, tag.id);
await tagGlobalItem(db, gi2.id, tag.id);
// Both tagged bikepacking, but only one matches "terrapin"
const results = await searchGlobalItems(db, "terrapin", ["bikepacking"]);
expect(results).toHaveLength(1);
expect(results[0].model).toBe("Terrapin System");
});
});
describe("getGlobalItemWithOwnerCount", () => {
it("returns item with ownerCount 0 when no items reference it", async () => {
const m = await insertManufacturer(db, "MSR", "msr");
const gi = await insertGlobalItem(db, {
manufacturerId: m.id,
model: "PocketRocket 2",
});
const result = await getGlobalItemWithOwnerCount(db, gi.id);
expect(result).not.toBeNull();
expect(result!.ownerCount).toBe(0);
expect(result!.brand).toBe("MSR");
});
it("returns ownerCount matching number of items with globalItemId", async () => {
const m = await insertManufacturer(db, "MSR", "msr");
const gi = await insertGlobalItem(db, {
manufacturerId: m.id,
model: "PocketRocket 2",
});
await insertItem(db, "My Stove", userId, { globalItemId: gi.id });
await insertItem(db, "Another Stove", userId, {
globalItemId: gi.id,
});
const result = await getGlobalItemWithOwnerCount(db, gi.id);
expect(result).not.toBeNull();
expect(result!.ownerCount).toBe(2);
});
it("returns null for non-existent id", async () => {
const result = await getGlobalItemWithOwnerCount(db, 9999);
expect(result).toBeNull();
});
});
describe("seedGlobalItems", () => {
it("inserts seed data on first call", async () => {
await seedGlobalItems(db);
const all = await db.select().from(globalItems);
expect(all.length).toBeGreaterThan(0);
});
it("is idempotent on second call", async () => {
await seedGlobalItems(db);
const countAfterFirst = (await db.select().from(globalItems)).length;
await seedGlobalItems(db);
const countAfterSecond = (await db.select().from(globalItems)).length;
expect(countAfterSecond).toBe(countAfterFirst);
});
});
describe("upsert operations", () => {
it("upsertGlobalItem creates new item and returns { item, created: true }", async () => {
await insertManufacturer(db, "Revelate Designs", "revelate-designs");
const result = await upsertGlobalItem(db, {
manufacturerSlug: "revelate-designs",
model: "Terrapin System",
category: "Bags",
weightGrams: 210,
});
expect(result.created).toBe(true);
expect(result.item.id).toBeDefined();
expect(result.item.model).toBe("Terrapin System");
});
it("upsertGlobalItem updates existing item on (manufacturerId, model) conflict and returns { item, created: false }", async () => {
await insertManufacturer(db, "MSR", "msr");
await upsertGlobalItem(db, {
manufacturerSlug: "msr",
model: "PocketRocket 2",
weightGrams: 83,
});
const second = await upsertGlobalItem(db, {
manufacturerSlug: "msr",
model: "PocketRocket 2",
weightGrams: 90,
});
expect(second.created).toBe(false);
expect(second.item.weightGrams).toBe(90);
// Only one row should exist
const all = await db.select().from(globalItems);
expect(all).toHaveLength(1);
});
it("upsertGlobalItem persists sourceUrl, imageCredit, imageSourceUrl", async () => {
await insertManufacturer(db, "Apidura", "apidura");
const result = await upsertGlobalItem(db, {
manufacturerSlug: "apidura",
model: "Handlebar Pack",
sourceUrl: "https://apidura.com/shop/handlebar-pack/",
imageCredit: "Apidura Ltd",
imageSourceUrl: "https://apidura.com/images/handlebar-pack.jpg",
});
expect(result.item.sourceUrl).toBe(
"https://apidura.com/shop/handlebar-pack/",
);
expect(result.item.imageCredit).toBe("Apidura Ltd");
expect(result.item.imageSourceUrl).toBe(
"https://apidura.com/images/handlebar-pack.jpg",
);
});
it("upsertGlobalItem with tags creates tags and links them", async () => {
await insertManufacturer(db, "Therm-a-Rest", "therm-a-rest");
const result = await upsertGlobalItem(db, {
manufacturerSlug: "therm-a-rest",
model: "NeoAir XLite",
tags: ["sleeping-pad", "ultralight"],
});
expect(result.created).toBe(true);
const linkedTags = await db
.select({ name: tags.name })
.from(globalItemTags)
.innerJoin(tags, eq(globalItemTags.tagId, tags.id))
.where(eq(globalItemTags.globalItemId, result.item.id));
expect(linkedTags).toHaveLength(2);
const tagNames = linkedTags.map((t) => t.name).sort();
expect(tagNames).toEqual(["sleeping-pad", "ultralight"]);
});
it("upsertGlobalItem without tags leaves existing tags untouched", async () => {
await insertManufacturer(db, "Sea to Summit", "sea-to-summit");
// Create item with tags
const first = await upsertGlobalItem(db, {
manufacturerSlug: "sea-to-summit",
model: "Spark III",
tags: ["sleeping-bag"],
});
// Upsert without tags
await upsertGlobalItem(db, {
manufacturerSlug: "sea-to-summit",
model: "Spark III",
weightGrams: 450,
});
// Tags should remain
const linkedTags = await db
.select()
.from(globalItemTags)
.where(eq(globalItemTags.globalItemId, first.item.id));
expect(linkedTags).toHaveLength(1);
});
it("upsertGlobalItem with empty tags array clears existing tags", async () => {
await insertManufacturer(db, "Big Agnes", "big-agnes");
// Create item with tags
const first = await upsertGlobalItem(db, {
manufacturerSlug: "big-agnes",
model: "Copper Spur HV UL2",
tags: ["tent", "ultralight"],
});
// Upsert with empty tags
await upsertGlobalItem(db, {
manufacturerSlug: "big-agnes",
model: "Copper Spur HV UL2",
tags: [],
});
// Tags should be cleared
const linkedTags = await db
.select()
.from(globalItemTags)
.where(eq(globalItemTags.globalItemId, first.item.id));
expect(linkedTags).toHaveLength(0);
});
it("bulkUpsertGlobalItems processes array and returns correct created/updated counts", async () => {
await insertManufacturer(db, "Petzl", "petzl");
await insertManufacturer(db, "Black Diamond", "black-diamond");
const result = await bulkUpsertGlobalItems(db, [
{ manufacturerSlug: "petzl", model: "Actik Core", weightGrams: 87 },
{
manufacturerSlug: "black-diamond",
model: "Spot 400",
weightGrams: 95,
},
{
manufacturerSlug: "black-diamond",
model: "Spot 350",
weightGrams: 90,
},
]);
expect(result.created).toBe(3);
expect(result.updated).toBe(0);
expect(result.items).toHaveLength(3);
});
it("bulkUpsertGlobalItems handles mix of new and existing items", async () => {
await insertManufacturer(db, "Petzl", "petzl");
await insertManufacturer(db, "Black Diamond", "black-diamond");
// Pre-insert one item
await upsertGlobalItem(db, {
manufacturerSlug: "petzl",
model: "Actik Core",
weightGrams: 87,
});
const result = await bulkUpsertGlobalItems(db, [
{ manufacturerSlug: "petzl", model: "Actik Core", weightGrams: 90 }, // existing
{
manufacturerSlug: "black-diamond",
model: "Spot 400",
weightGrams: 95,
}, // new
]);
expect(result.created).toBe(1);
expect(result.updated).toBe(1);
expect(result.items).toHaveLength(2);
});
});
});
describe("listGlobalItemsForAdmin", () => {
let db: TestDb["db"];
beforeEach(async () => {
({ db } = await createTestDb());
});
it("returns empty result when no items exist", async () => {
const result = await listGlobalItemsForAdmin(db);
expect(result.items).toHaveLength(0);
expect(result.total).toBe(0);
expect(result.hasMore).toBe(false);
});
it("returns paginated items with total count", async () => {
const mfr = await insertManufacturer(db);
await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Alpha" });
await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Beta" });
await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Gamma" });
const result = await listGlobalItemsForAdmin(db, { limit: 2, offset: 0 });
expect(result.items).toHaveLength(2);
expect(result.total).toBe(3);
expect(result.hasMore).toBe(true);
expect(result.nextOffset).toBe(2);
});
it("filters by query string (brand/model)", async () => {
const mfr = await insertManufacturer(db, "Salsa", "salsa");
await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Woodsmoke 700" });
const mfr2 = await insertManufacturer(db, "Apidura", "apidura");
await insertGlobalItem(db, { manufacturerId: mfr2.id, model: "Racing Saddle Bag" });
const result = await listGlobalItemsForAdmin(db, { query: "salsa" });
expect(result.items).toHaveLength(1);
expect(result.items[0]!.model).toBe("Woodsmoke 700");
});
it("includes tags and ownerCount per item", async () => {
const mfr = await insertManufacturer(db);
const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Test Item" });
const tag = await insertTag(db, "bikepacking");
await tagGlobalItem(db, globalItem.id, tag.id!);
// Insert a user and item linking to the global item
const [user] = await db
.insert(schema.users)
.values({ logtoSub: "test-sub" })
.returning();
await insertItem(db, "My Test Item", user!.id, { globalItemId: globalItem.id });
const result = await listGlobalItemsForAdmin(db);
expect(result.items).toHaveLength(1);
expect(result.items[0]!.tags).toContain("bikepacking");
expect(result.items[0]!.ownerCount).toBe(1);
});
});
describe("updateGlobalItemById", () => {
let db: TestDb["db"];
beforeEach(async () => {
({ db } = await createTestDb());
});
it("returns null for non-existent item", async () => {
const result = await updateGlobalItemById(db, 99999, { model: "Ghost" });
expect(result).toBeNull();
});
it("updates model field by id", async () => {
const mfr = await insertManufacturer(db);
const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Original" });
await updateGlobalItemById(db, globalItem.id, { model: "Updated" });
const updated = await getGlobalItemWithOwnerCount(db, globalItem.id);
expect(updated?.model).toBe("Updated");
});
it("syncs tags when tags array provided", async () => {
const mfr = await insertManufacturer(db);
const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Tagged Item" });
await updateGlobalItemById(db, globalItem.id, { tags: ["cycling", "gravel"] });
const result = await listGlobalItemsForAdmin(db);
const found = result.items.find((i) => i.id === globalItem.id);
expect(found?.tags).toContain("cycling");
expect(found?.tags).toContain("gravel");
});
});
describe("deleteGlobalItem", () => {
let db: TestDb["db"];
beforeEach(async () => {
({ db } = await createTestDb());
});
it("returns false for non-existent item", async () => {
const result = await deleteGlobalItem(db, 99999);
expect(result).toBe(false);
});
it("deletes item and returns true", async () => {
const mfr = await insertManufacturer(db);
const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "To Delete" });
const result = await deleteGlobalItem(db, globalItem.id);
expect(result).toBe(true);
const found = await getGlobalItemWithOwnerCount(db, globalItem.id);
expect(found).toBeNull();
});
it("nullifies items.globalItemId before deleting", async () => {
const mfr = await insertManufacturer(db);
const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Owned Item" });
const [user] = await db
.insert(schema.users)
.values({ logtoSub: "delete-test-sub" })
.returning();
const userItem = await insertItem(db, "User Item", user!.id, { globalItemId: globalItem.id });
await deleteGlobalItem(db, globalItem.id);
const [afterDelete] = await db
.select({ globalItemId: items.globalItemId })
.from(items)
.where(eq(items.id, userItem!.id));
expect(afterDelete?.globalItemId).toBeNull();
});
it("removes globalItemTags before deleting", async () => {
const mfr = await insertManufacturer(db);
const globalItem = await insertGlobalItem(db, { manufacturerId: mfr.id, model: "Tagged Delete" });
const tag = await insertTag(db, "delete-tag");
await tagGlobalItem(db, globalItem.id, tag.id!);
await deleteGlobalItem(db, globalItem.id);
const remainingTags = await db
.select()
.from(globalItemTags)
.where(eq(globalItemTags.globalItemId, globalItem.id));
expect(remainingTags).toHaveLength(0);
});
});