From 84e6db740616b8e306e579de8ea51b1d2a461f46 Mon Sep 17 00:00:00 2001 From: toanhoang Date: Wed, 19 Aug 2026 22:07:32 -0400 Subject: [PATCH] test: add Area, InventoryScan and bot API tests 369/369 tests pass (35 new), tsc --noEmit clean, next lint clean. Not yet run through the repos code-reviewer subagent gate (this session operated via SSH, cwd was not the project dir so .claude/agents/ subagents were not recognized) -- see CLAUDE.md note. Co-Authored-By: Claude Sonnet 5 --- tests/areas.test.ts | 118 +++++++++++++++++++ tests/bot-api.test.ts | 214 ++++++++++++++++++++++++++++++++++ tests/inventory-scans.test.ts | 196 +++++++++++++++++++++++++++++++ 3 files changed, 528 insertions(+) create mode 100644 tests/areas.test.ts create mode 100644 tests/bot-api.test.ts create mode 100644 tests/inventory-scans.test.ts diff --git a/tests/areas.test.ts b/tests/areas.test.ts new file mode 100644 index 0000000..3771306 --- /dev/null +++ b/tests/areas.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Mock } from "vitest"; +import type { Session } from "next-auth"; + +// Mirrors tests/employees.test.ts's @/auth mocking pattern. +vi.mock("@/auth", () => ({ auth: vi.fn() })); + +import { GET, POST } from "@/app/api/areas/route"; +import { prisma } from "@/lib/prisma"; +import { auth } from "@/auth"; + +const mockedAuth = auth as unknown as Mock<() => Promise>; + +const testSession: Session = { + user: { id: "test-user-id" }, + expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(), +}; + +function postRequest(body: unknown): Request { + return new Request("http://localhost/api/areas", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(async () => { + await prisma.area.deleteMany(); + mockedAuth.mockResolvedValue(testSession); +}); + +describe("POST /api/areas", () => { + it("creates an area with no team on valid input", async () => { + const res = await POST(postRequest({ name: "Phong hop A" })); + + expect(res.status).toBe(201); + const body = await res.json(); + expect(body).toMatchObject({ name: "Phong hop A", team: null }); + expect(typeof body.id).toBe("string"); + }); + + it("creates an area with a team", async () => { + const res = await POST(postRequest({ name: "Seat 12", team: "Backend" })); + + expect(res.status).toBe(201); + const body = await res.json(); + expect(body).toMatchObject({ name: "Seat 12", team: "Backend" }); + }); + + it("rejects an empty name", async () => { + const res = await POST(postRequest({ name: "" })); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(typeof body.error).toBe("string"); + }); + + // See src/lib/areas.ts's createOrGetArea() comment: this is deliberate, + // not accidental idempotency — the bot's "pick from list or type new" + // flow must be able to re-submit a name it just showed as an existing + // option without erroring. + it("returns the existing row (200-equivalent 201, same id) instead of erroring on a duplicate name", async () => { + const first = await POST(postRequest({ name: "Kho tang 2" })); + const firstBody = await first.json(); + + const second = await POST(postRequest({ name: "Kho tang 2" })); + expect(second.status).toBe(201); + const secondBody = await second.json(); + + expect(secondBody.id).toBe(firstBody.id); + + const all = await prisma.area.findMany(); + expect(all).toHaveLength(1); + }); + + it("rejects an invalid JSON body", async () => { + const req = new Request("http://localhost/api/areas", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "not json", + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); +}); + +describe("GET /api/areas", () => { + it("lists areas alphabetically by name", async () => { + await POST(postRequest({ name: "Zeta" })); + await POST(postRequest({ name: "Alpha" })); + + const res = await GET(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.map((a: { name: string }) => a.name)).toEqual(["Alpha", "Zeta"]); + }); + + it("returns an empty list when there are no areas", async () => { + const res = await GET(); + expect(res.status).toBe(200); + expect(await res.json()).toEqual([]); + }); +}); + +describe("auth gating on /api/areas", () => { + it("POST /api/areas returns 401 without a session", async () => { + mockedAuth.mockResolvedValue(null); + const res = await POST(postRequest({ name: "Phong hop A" })); + expect(res.status).toBe(401); + expect(await prisma.area.findMany()).toHaveLength(0); + }); + + it("GET /api/areas returns 401 without a session", async () => { + mockedAuth.mockResolvedValue(null); + const res = await GET(); + expect(res.status).toBe(401); + }); +}); diff --git a/tests/bot-api.test.ts b/tests/bot-api.test.ts new file mode 100644 index 0000000..ed334d7 --- /dev/null +++ b/tests/bot-api.test.ts @@ -0,0 +1,214 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +// Covers every /api/bot/* route — these are authed by a shared secret +// header (src/lib/bot-auth.ts), not an Auth.js session, since the caller is +// the inventory-capture Telegram bot service, not a browser. No @/auth +// mock needed here (unlike tests/employees.test.ts and friends). +process.env.BOT_INGEST_SECRET = "test-bot-secret"; + +import { GET as getCatalog } from "@/app/api/bot/catalog/route"; +import { GET as getBotAreas, POST as postBotAreas } from "@/app/api/bot/areas/route"; +import { GET as getBotEmployees, POST as postBotEmployees } from "@/app/api/bot/employees/route"; +import { POST as postBotAssetModel } from "@/app/api/bot/asset-models/route"; +import { POST as postBotInventoryScan } from "@/app/api/bot/inventory-scans/route"; +import { prisma } from "@/lib/prisma"; + +const AUTH_HEADER = { authorization: "Bearer test-bot-secret" }; + +function req(url: string, opts: { method?: string; body?: unknown; auth?: boolean } = {}): Request { + return new Request(`http://localhost${url}`, { + method: opts.method ?? "GET", + headers: { + "content-type": "application/json", + ...(opts.auth === false ? {} : AUTH_HEADER), + }, + ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}), + }); +} + +async function seedCategoryAndModel() { + const category = await prisma.assetCategory.create({ + data: { name: "Workstation", valueGroup: "high_value" }, + }); + await prisma.assetCategoryAttributeDef.create({ + data: { categoryId: category.id, key: "cpu", label: "CPU", dataType: "string", required: false, sortOrder: 1 }, + }); + const model = await prisma.assetModel.create({ + data: { categoryId: category.id, manufacturer: "HP", name: "Z4 G4", specs: {} }, + }); + return { category, model }; +} + +beforeEach(async () => { + await prisma.inventoryScan.deleteMany(); + await prisma.assetCategoryAttributeDef.deleteMany(); + await prisma.assetModel.deleteMany(); + await prisma.assetCategory.deleteMany(); + await prisma.area.deleteMany(); + await prisma.employee.deleteMany(); +}); + +describe("GET /api/bot/catalog", () => { + it("returns categories with nested models and attribute defs", async () => { + await seedCategoryAndModel(); + + const res = await getCatalog(req("/api/bot/catalog")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.categories).toHaveLength(1); + expect(body.categories[0]).toMatchObject({ + name: "Workstation", + models: [{ manufacturer: "HP", name: "Z4 G4" }], + attribute_defs: [{ key: "cpu", label: "CPU", data_type: "string" }], + }); + }); + + it("returns 401 with a wrong secret", async () => { + const res = await getCatalog( + new Request("http://localhost/api/bot/catalog", { + headers: { authorization: "Bearer wrong-secret" }, + }) + ); + expect(res.status).toBe(401); + }); + + it("returns 401 with no auth header", async () => { + const res = await getCatalog(req("/api/bot/catalog", { auth: false })); + expect(res.status).toBe(401); + }); +}); + +describe("/api/bot/areas", () => { + it("POST creates an area, and re-posting the same name returns the same row (idempotent)", async () => { + const first = await postBotAreas(req("/api/bot/areas", { method: "POST", body: { name: "Seat 12" } })); + expect(first.status).toBe(201); + const firstBody = await first.json(); + + const second = await postBotAreas(req("/api/bot/areas", { method: "POST", body: { name: "Seat 12" } })); + const secondBody = await second.json(); + expect(secondBody.id).toBe(firstBody.id); + }); + + it("GET lists areas", async () => { + await postBotAreas(req("/api/bot/areas", { method: "POST", body: { name: "Seat 12" } })); + const res = await getBotAreas(req("/api/bot/areas")); + const body = await res.json(); + expect(body.areas).toHaveLength(1); + }); + + it("POST returns 401 without the bot secret", async () => { + const res = await postBotAreas( + req("/api/bot/areas", { method: "POST", body: { name: "Seat 12" }, auth: false }) + ); + expect(res.status).toBe(401); + }); +}); + +describe("/api/bot/employees", () => { + it("POST creates an employee, defaulting department when omitted", async () => { + const res = await postBotEmployees( + req("/api/bot/employees", { method: "POST", body: { name: "Toan Hoang" } }) + ); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.name).toBe("Toan Hoang"); + expect(body.department).toBeTruthy(); + }); + + it("GET lists employees", async () => { + await postBotEmployees(req("/api/bot/employees", { method: "POST", body: { name: "Toan Hoang" } })); + const res = await getBotEmployees(req("/api/bot/employees")); + const body = await res.json(); + expect(body.employees).toHaveLength(1); + }); + + it("POST returns 401 without the bot secret", async () => { + const res = await postBotEmployees( + req("/api/bot/employees", { method: "POST", body: { name: "Toan" }, auth: false }) + ); + expect(res.status).toBe(401); + }); +}); + +describe("POST /api/bot/asset-models", () => { + it("creates a model within an existing high_value category", async () => { + const { category } = await seedCategoryAndModel(); + + const res = await postBotAssetModel( + req("/api/bot/asset-models", { + method: "POST", + body: { categoryId: category.id, manufacturer: "Lenovo", name: "P620" }, + }) + ); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body).toMatchObject({ manufacturer: "Lenovo", name: "P620" }); + }); + + it("returns 401 without the bot secret", async () => { + const { category } = await seedCategoryAndModel(); + const res = await postBotAssetModel( + req("/api/bot/asset-models", { + method: "POST", + body: { categoryId: category.id, manufacturer: "Lenovo", name: "P620" }, + auth: false, + }) + ); + expect(res.status).toBe(401); + }); +}); + +describe("POST /api/bot/inventory-scans", () => { + it("creates a scan with specs, asset tag and serial number", async () => { + const { category, model } = await seedCategoryAndModel(); + + const res = await postBotInventoryScan( + req("/api/bot/inventory-scans", { + method: "POST", + body: { + categoryId: category.id, + modelId: model.id, + assetTag: "TAG-1", + serialNumber: "SN-1", + specs: { cpu: "i7" }, + telegramChatId: "12345", + }, + }) + ); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body).toMatchObject({ + category_name: "Workstation", + model_name: "Z4 G4", + asset_tag: "TAG-1", + serial_number: "SN-1", + specs: { cpu: "i7" }, + }); + }); + + it("rejects a categoryId/modelId that doesn't reference an existing row", async () => { + const res = await postBotInventoryScan( + req("/api/bot/inventory-scans", { + method: "POST", + body: { + categoryId: "00000000-0000-0000-0000-000000000000", + modelId: "00000000-0000-0000-0000-000000000000", + }, + }) + ); + expect(res.status).toBe(400); + }); + + it("returns 401 without the bot secret", async () => { + const { category, model } = await seedCategoryAndModel(); + const res = await postBotInventoryScan( + req("/api/bot/inventory-scans", { + method: "POST", + body: { categoryId: category.id, modelId: model.id }, + auth: false, + }) + ); + expect(res.status).toBe(401); + expect(await prisma.inventoryScan.findMany()).toHaveLength(0); + }); +}); diff --git a/tests/inventory-scans.test.ts b/tests/inventory-scans.test.ts new file mode 100644 index 0000000..433872a --- /dev/null +++ b/tests/inventory-scans.test.ts @@ -0,0 +1,196 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Mock } from "vitest"; +import type { Session } from "next-auth"; + +// Mirrors tests/employees.test.ts's @/auth mocking pattern. Creation of +// InventoryScan rows only happens via the bot-secret-authed +// /api/bot/inventory-scans route (see tests/bot-api.test.ts) — this file +// covers the session-authed human routes only (list/edit/delete), so test +// data is seeded directly via prisma, not through POST. +vi.mock("@/auth", () => ({ auth: vi.fn() })); + +import { GET as listScans } from "@/app/api/inventory-scans/route"; +import { PATCH, DELETE } from "@/app/api/inventory-scans/[id]/route"; +import { prisma } from "@/lib/prisma"; +import { auth } from "@/auth"; + +const mockedAuth = auth as unknown as Mock<() => Promise>; + +const testSession: Session = { + user: { id: "test-user-id" }, + expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(), +}; + +async function seedCategoryAndModel() { + const category = await prisma.assetCategory.create({ + data: { name: "Controller", valueGroup: "high_value" }, + }); + const model = await prisma.assetModel.create({ + data: { categoryId: category.id, manufacturer: "Sony", name: "PS5", specs: {} }, + }); + const model2 = await prisma.assetModel.create({ + data: { categoryId: category.id, manufacturer: "Microsoft", name: "Xbox", specs: {} }, + }); + return { category, model, model2 }; +} + +function patchRequest(body: unknown): Request { + return new Request("http://localhost/api/inventory-scans/x", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(async () => { + await prisma.inventoryScan.deleteMany(); + await prisma.assetModel.deleteMany(); + await prisma.assetCategory.deleteMany(); + await prisma.area.deleteMany(); + await prisma.employee.deleteMany(); + mockedAuth.mockResolvedValue(testSession); +}); + +describe("GET /api/inventory-scans", () => { + it("lists scans newest first, with joined category/model names", async () => { + const { category, model } = await seedCategoryAndModel(); + const scan = await prisma.inventoryScan.create({ + data: { categoryId: category.id, modelId: model.id, assetTag: "TAG-1" }, + }); + + const res = await listScans(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveLength(1); + expect(body[0]).toMatchObject({ + id: scan.id, + category_name: "Controller", + model_name: "PS5", + model_manufacturer: "Sony", + asset_tag: "TAG-1", + area_name: null, + assigned_employee_name: null, + }); + }); + + it("returns an empty list when there are no scans", async () => { + const res = await listScans(); + expect(res.status).toBe(200); + expect(await res.json()).toEqual([]); + }); +}); + +describe("PATCH /api/inventory-scans/[id]", () => { + it("updates area, assignee, asset tag, serial number and specs", async () => { + const { category, model } = await seedCategoryAndModel(); + const scan = await prisma.inventoryScan.create({ + data: { categoryId: category.id, modelId: model.id }, + }); + const area = await prisma.area.create({ data: { name: "Phong hop A" } }); + const employee = await prisma.employee.create({ data: { name: "Toan", department: "IT" } }); + + const res = await PATCH( + patchRequest({ + areaId: area.id, + assignedEmployeeId: employee.id, + assetTag: "TAG-2", + serialNumber: "SN-123", + specs: { cpu: "i7" }, + }), + { params: { id: scan.id } } + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toMatchObject({ + area_name: "Phong hop A", + assigned_employee_name: "Toan", + asset_tag: "TAG-2", + serial_number: "SN-123", + specs: { cpu: "i7" }, + }); + }); + + it("allows switching to a different model within the same category", async () => { + const { category, model, model2 } = await seedCategoryAndModel(); + const scan = await prisma.inventoryScan.create({ + data: { categoryId: category.id, modelId: model.id }, + }); + + const res = await PATCH(patchRequest({ modelId: model2.id }), { params: { id: scan.id } }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.model_name).toBe("Xbox"); + }); + + it("clears area/assignee when explicitly set to null", async () => { + const { category, model } = await seedCategoryAndModel(); + const area = await prisma.area.create({ data: { name: "Phong hop A" } }); + const scan = await prisma.inventoryScan.create({ + data: { categoryId: category.id, modelId: model.id, areaId: area.id }, + }); + + const res = await PATCH(patchRequest({ areaId: null }), { params: { id: scan.id } }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.area_id).toBeNull(); + }); + + it("returns 404 for an id that doesn't exist", async () => { + const res = await PATCH(patchRequest({ assetTag: "x" }), { + params: { id: "00000000-0000-0000-0000-000000000000" }, + }); + expect(res.status).toBe(404); + }); + + it("returns 400 for a malformed id", async () => { + const res = await PATCH(patchRequest({ assetTag: "x" }), { params: { id: "not-a-uuid" } }); + expect(res.status).toBe(400); + }); +}); + +describe("DELETE /api/inventory-scans/[id]", () => { + it("deletes an existing scan", async () => { + const { category, model } = await seedCategoryAndModel(); + const scan = await prisma.inventoryScan.create({ + data: { categoryId: category.id, modelId: model.id }, + }); + + const res = await DELETE(new Request("http://localhost/api/inventory-scans/x", { method: "DELETE" }), { + params: { id: scan.id }, + }); + expect(res.status).toBe(204); + expect(await prisma.inventoryScan.findUnique({ where: { id: scan.id } })).toBeNull(); + }); + + it("returns 404 for an id that doesn't exist", async () => { + const res = await DELETE(new Request("http://localhost/api/inventory-scans/x", { method: "DELETE" }), { + params: { id: "00000000-0000-0000-0000-000000000000" }, + }); + expect(res.status).toBe(404); + }); +}); + +describe("auth gating on /api/inventory-scans", () => { + it("GET returns 401 without a session", async () => { + mockedAuth.mockResolvedValue(null); + const res = await listScans(); + expect(res.status).toBe(401); + }); + + it("PATCH returns 401 without a session", async () => { + mockedAuth.mockResolvedValue(null); + const res = await PATCH(patchRequest({ assetTag: "x" }), { + params: { id: "00000000-0000-0000-0000-000000000000" }, + }); + expect(res.status).toBe(401); + }); + + it("DELETE returns 401 without a session", async () => { + mockedAuth.mockResolvedValue(null); + const res = await DELETE(new Request("http://localhost/api/inventory-scans/x", { method: "DELETE" }), { + params: { id: "00000000-0000-0000-0000-000000000000" }, + }); + expect(res.status).toBe(401); + }); +});