pelagia-portal/App/tests/integration/products-search.test.ts
Hardik 938ff6df89 test+ci: green the test baseline and make type-check + unit tests hard gates
Green-lights the test suite so the PR checks can enforce it:
- Fix the NextAuth v5 auth() mock typing across all integration tests (cast to a
  simple async fn so mockResolvedValue accepts the session) — clears ~86 errors.
- Fix stale test values: intent 'resubmit'->'submit' / 'save'->'draft'; ParsedImportLine
  .description -> .name; approvepo -> approvePo; add missing beforeEach/beforeAll imports.
- permissions: MANAGER *can* process_payment (intentional since e1340b9) — update the
  stale assertion.
- po-import-parser: skip the Sample_PO.xlsx fixture tests when the file is absent (it
  lives outside the repo); synthetic-workbook tests still cover the parser.

type-check is now 0 errors and unit tests pass (167 passed, 13 skipped). pr-checks.yml
flips type-check (whole project) and unit tests to HARD gates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 13:03:54 +05:30

145 lines
5.5 KiB
TypeScript

/**
* Integration tests for GET /api/products/search.
* Tests authorization, query validation, filtering, and Decimal serialisation.
*/
import { vi, describe, it, expect, beforeAll, beforeEach } from "vitest";
vi.mock("@/auth", () => ({ auth: vi.fn() }));
import { auth } from "@/auth";
import { NextRequest } from "next/server";
import { GET } from "@/app/api/products/search/route";
import { makeSession, getSeedUser } from "./helpers";
let techId: string;
let accountsId: string;
beforeAll(async () => {
const [tech, acct] = await Promise.all([
getSeedUser("tech@pelagia.local"),
getSeedUser("accounts@pelagia.local"),
]);
techId = tech.id;
accountsId = acct.id;
});
function makeRequest(query: string) {
return new NextRequest(`http://localhost/api/products/search?q=${encodeURIComponent(query)}`);
}
// ── Authorization ─────────────────────────────────────────────────────────────
describe("GET /api/products/search — authorization", () => {
it("returns 401 for unauthenticated requests", async () => {
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(null);
const res = await GET(makeRequest("oil"));
expect(res.status).toBe(401);
});
it("TECHNICAL can search products", async () => {
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const res = await GET(makeRequest("oil"));
expect(res.status).toBe(200);
});
it("ACCOUNTS can search products", async () => {
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
const res = await GET(makeRequest("oil"));
expect(res.status).toBe(200);
});
});
// ── Query validation ──────────────────────────────────────────────────────────
describe("GET /api/products/search — query validation", () => {
beforeEach(() => {
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
});
it("returns empty array for query shorter than 2 chars", async () => {
const res = await GET(makeRequest("a"));
const data = await res.json();
expect(data).toEqual([]);
});
it("returns empty array for empty query", async () => {
const res = await GET(makeRequest(""));
const data = await res.json();
expect(data).toEqual([]);
});
it("returns results for query of exactly 2 chars", async () => {
const res = await GET(makeRequest("oi"));
const data = await res.json();
expect(Array.isArray(data)).toBe(true);
});
});
// ── Search behaviour ──────────────────────────────────────────────────────────
describe("GET /api/products/search — search behaviour", () => {
beforeEach(() => {
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
});
it("finds products by name substring", async () => {
const res = await GET(makeRequest("Gear Oil"));
const data: { name: string }[] = await res.json();
expect(data.some((p) => p.name.toLowerCase().includes("gear oil"))).toBe(true);
});
it("finds products by product code", async () => {
const res = await GET(makeRequest("LUBE"));
const data: { code: string }[] = await res.json();
expect(data.every((p) => p.code.toUpperCase().includes("LUBE"))).toBe(true);
});
it("finds products by description text", async () => {
const res = await GET(makeRequest("turbocharger"));
const data: { description: string | null }[] = await res.json();
expect(data.length).toBeGreaterThan(0);
expect(data.some((p) => p.description?.toLowerCase().includes("turbocharger"))).toBe(true);
});
it("search is case-insensitive", async () => {
const [upper, lower] = await Promise.all([
GET(makeRequest("GEAR OIL")).then((r) => r.json()),
GET(makeRequest("gear oil")).then((r) => r.json()),
]);
expect(upper.length).toBe(lower.length);
});
it("returns at most 10 results", async () => {
// Query a broad term likely to match many products
const res = await GET(makeRequest("a"));
const data: unknown[] = await res.json();
expect(data.length).toBeLessThanOrEqual(10);
});
it("serialises lastPrice as a plain number, not a Decimal object", async () => {
const res = await GET(makeRequest("Gear Oil"));
const data: { lastPrice: unknown }[] = await res.json();
const withPrice = data.find((p) => p.lastPrice !== null);
if (withPrice) {
expect(typeof withPrice.lastPrice).toBe("number");
}
});
it("excludes inactive products from results", async () => {
const { db } = await import("@/lib/db");
// Deactivate a known product temporarily
const product = await db.product.findFirst({
where: { code: "LUBE-EP80W90", isActive: true },
});
if (!product) return;
await db.product.update({ where: { id: product.id }, data: { isActive: false } });
try {
const res = await GET(makeRequest("EP 80W90"));
const data: { code: string }[] = await res.json();
expect(data.find((p) => p.code === "LUBE-EP80W90")).toBeUndefined();
} finally {
await db.product.update({ where: { id: product.id }, data: { isActive: true } });
}
});
});