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 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
77dbd15450
commit
84e6db7406
@@ -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<Session | null>>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<Session | null>>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user