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)
This commit is contained in:
2026-04-19 21:32:42 +02:00
parent 3c79b7eb9a
commit db471001fa
4 changed files with 466 additions and 0 deletions

View File

@@ -10,8 +10,11 @@ import {
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";
@@ -503,3 +506,153 @@ describe("Global Item Service", () => {
});
});
});
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);
});
});