Reworks the T&C feature per review:
- categories are user-defined DATA, not a fixed enum — admins add new ones;
- ALL PO T&Cs are catalogued, incl. the previously-fixed boilerplate (seeded
under a "General" category) and an "Others" bucket;
- the PO form is a dynamic editor: "+ Add term", pick a category, type/pick a
clause.
- schema: TermsCategory (name/sortOrder/isActive) + TermsCondition (categoryId
FK + text + isDefault + isActive). PurchaseOrder.terms Json snapshot. Migration
seeds every standard line as a clause (named slots, the two fixed lines under
General, empty Others); isDefault rows pre-fill new POs.
- admin /admin/terms: Add/Edit clause form's category is a combobox — typing a
new name creates the category; isDefault checkbox.
- PO editor components/po/po-terms-editor.tsx: dynamic rows (category + clause
comboboxes), used by new/edit/manager-edit forms; new POs pre-fill from
getDefaultPoTerms, edits load po.terms or legacyPoTerms (old tc* + fixed lines).
- storage: PurchaseOrder.terms ([{category,text}]) supersedes tc* for export +
detail; null on old POs falls back to tc* + fixed lines. parsePoTerms validates.
- export route + po-detail render from terms when present.
- tests rewritten for category creation + catalogue/default helpers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
109 lines
5.2 KiB
TypeScript
109 lines
5.2 KiB
TypeScript
/**
|
|
* Integration tests for the Terms & Conditions admin CRUD (issue #11).
|
|
* Categories are user-defined data: adding a clause under a new category name
|
|
* creates the category. Covers CRUD + the manage_terms guard + the catalogue /
|
|
* default-terms helpers that feed the PO editor.
|
|
*/
|
|
import { vi, describe, it, expect, afterAll } from "vitest";
|
|
|
|
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
|
|
|
import { auth } from "@/auth";
|
|
import { db } from "@/lib/db";
|
|
import {
|
|
createTerm,
|
|
updateTerm,
|
|
toggleTermActive,
|
|
deleteTerm,
|
|
} from "@/app/(portal)/admin/terms/actions";
|
|
import { getTermsCatalogue, getDefaultPoTerms } from "@/lib/terms-data";
|
|
import { makeSession, fd } from "./helpers";
|
|
|
|
const mockedAuth = vi.mocked(auth);
|
|
const PREFIX = "INTTEST_TERMS_";
|
|
const asManager = () => mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
|
|
|
afterAll(async () => {
|
|
await db.termsCondition.deleteMany({ where: { text: { startsWith: PREFIX } } });
|
|
await db.termsCategory.deleteMany({ where: { name: { startsWith: PREFIX } } });
|
|
});
|
|
|
|
describe("createTerm", () => {
|
|
it("creates a new category on the fly and the clause under it", async () => {
|
|
asManager();
|
|
const result = await createTerm(fd({ categoryName: `${PREFIX}Warranty`, text: `${PREFIX}12 months` }));
|
|
expect(result).toEqual({ ok: true });
|
|
|
|
const cat = await db.termsCategory.findFirstOrThrow({ where: { name: `${PREFIX}Warranty` }, include: { clauses: true } });
|
|
expect(cat.clauses).toHaveLength(1);
|
|
expect(cat.clauses[0].text).toBe(`${PREFIX}12 months`);
|
|
});
|
|
|
|
it("reuses an existing category (case-insensitive) for a second clause", async () => {
|
|
asManager();
|
|
await createTerm(fd({ categoryName: `${PREFIX}warranty`, text: `${PREFIX}24 months` }));
|
|
const cats = await db.termsCategory.findMany({ where: { name: { startsWith: PREFIX, mode: "insensitive" }, AND: { name: { equals: `${PREFIX}Warranty`, mode: "insensitive" } } } });
|
|
expect(cats).toHaveLength(1); // no duplicate category
|
|
});
|
|
|
|
it("requires a category and clause text", async () => {
|
|
asManager();
|
|
expect("error" in (await createTerm(fd({ categoryName: " ", text: "x" })))).toBe(true);
|
|
expect("error" in (await createTerm(fd({ categoryName: `${PREFIX}X`, text: " " })))).toBe(true);
|
|
});
|
|
|
|
it("refuses callers without manage_terms", async () => {
|
|
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
|
|
expect(await createTerm(fd({ categoryName: `${PREFIX}X`, text: `${PREFIX}nope` }))).toEqual({ error: "Forbidden" });
|
|
mockedAuth.mockResolvedValue(makeSession("u-acc", "ACCOUNTS") as never);
|
|
expect(await createTerm(fd({ categoryName: `${PREFIX}X`, text: `${PREFIX}nope` }))).toEqual({ error: "Forbidden" });
|
|
});
|
|
});
|
|
|
|
describe("updateTerm / toggle / delete", () => {
|
|
it("edits, toggles active, then deletes a clause", async () => {
|
|
asManager();
|
|
await createTerm(fd({ categoryName: `${PREFIX}Edit`, text: `${PREFIX}old` }));
|
|
const t = await db.termsCondition.findFirstOrThrow({ where: { text: `${PREFIX}old` } });
|
|
|
|
expect(await updateTerm(t.id, fd({ categoryName: `${PREFIX}Edit2`, text: `${PREFIX}new` }))).toEqual({ ok: true });
|
|
const after = await db.termsCondition.findUniqueOrThrow({ where: { id: t.id }, include: { category: true } });
|
|
expect(after.text).toBe(`${PREFIX}new`);
|
|
expect(after.category.name).toBe(`${PREFIX}Edit2`);
|
|
|
|
expect(await toggleTermActive(t.id)).toEqual({ ok: true });
|
|
expect((await db.termsCondition.findUniqueOrThrow({ where: { id: t.id } })).isActive).toBe(false);
|
|
|
|
expect(await deleteTerm(t.id)).toEqual({ ok: true });
|
|
expect(await db.termsCondition.findUnique({ where: { id: t.id } })).toBeNull();
|
|
});
|
|
|
|
it("guards update/toggle/delete behind the permission", async () => {
|
|
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
|
|
expect(await updateTerm("x", fd({ categoryName: `${PREFIX}X`, text: "y" }))).toEqual({ error: "Forbidden" });
|
|
expect(await toggleTermActive("x")).toEqual({ error: "Forbidden" });
|
|
expect(await deleteTerm("x")).toEqual({ error: "Forbidden" });
|
|
});
|
|
});
|
|
|
|
describe("catalogue + default terms helpers", () => {
|
|
it("getTermsCatalogue exposes active categories with their active clauses", async () => {
|
|
asManager();
|
|
await createTerm(fd({ categoryName: `${PREFIX}Cat`, text: `${PREFIX}active clause` }));
|
|
await createTerm(fd({ categoryName: `${PREFIX}Cat`, text: `${PREFIX}inactive clause` }));
|
|
const inactive = await db.termsCondition.findFirstOrThrow({ where: { text: `${PREFIX}inactive clause` } });
|
|
await toggleTermActive(inactive.id);
|
|
|
|
const cat = (await getTermsCatalogue()).find((c) => c.name === `${PREFIX}Cat`);
|
|
expect(cat?.clauses).toContain(`${PREFIX}active clause`);
|
|
expect(cat?.clauses).not.toContain(`${PREFIX}inactive clause`);
|
|
});
|
|
|
|
it("getDefaultPoTerms returns isDefault clauses", async () => {
|
|
asManager();
|
|
await createTerm(fd({ categoryName: `${PREFIX}Def`, text: `${PREFIX}default clause`, isDefault: "true" }));
|
|
const defaults = await getDefaultPoTerms();
|
|
expect(defaults.some((d) => d.text === `${PREFIX}default clause` && d.category === `${PREFIX}Def`)).toBe(true);
|
|
});
|
|
});
|