test: add License API tests
This commit is contained in:
@@ -0,0 +1,467 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Mock } from "vitest";
|
||||||
|
import type { Session } from "next-auth";
|
||||||
|
import {
|
||||||
|
createLicense,
|
||||||
|
listLicenses,
|
||||||
|
validateCreateLicenseInput,
|
||||||
|
} from "@/lib/licenses";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
// Note: this file has two layers of coverage, mirroring tests/assets.test.ts:
|
||||||
|
// - lib-level tests below exercise src/lib/licenses.ts directly (validation,
|
||||||
|
// seatsUsed always 0, list ordering).
|
||||||
|
// - route-level tests (further down) exercise src/app/api/licenses/route.ts
|
||||||
|
// over HTTP, including the auth() gate — mirrors tests/assets.test.ts's
|
||||||
|
// @/auth mocking pattern.
|
||||||
|
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||||
|
|
||||||
|
import { GET, POST } from "@/app/api/licenses/route";
|
||||||
|
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/licenses", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Each test starts from an empty table so listing/order assertions are
|
||||||
|
// deterministic.
|
||||||
|
await prisma.license.deleteMany();
|
||||||
|
// Default to a logged-in session — the lib-level tests below don't go
|
||||||
|
// through the route at all (so this is a no-op for them), and the
|
||||||
|
// route-level tests exercise business logic, not auth gating (that's
|
||||||
|
// covered by the "auth gating" describe block further down).
|
||||||
|
mockedAuth.mockResolvedValue(testSession);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateCreateLicenseInput", () => {
|
||||||
|
it("accepts valid input with an expiryDate", () => {
|
||||||
|
const result = validateCreateLicenseInput({
|
||||||
|
name: "Adobe Creative Cloud",
|
||||||
|
vendor: "Adobe",
|
||||||
|
seatsTotal: 10,
|
||||||
|
expiryDate: "2027-01-15",
|
||||||
|
licenseType: "subscription",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.value).toMatchObject({
|
||||||
|
name: "Adobe Creative Cloud",
|
||||||
|
vendor: "Adobe",
|
||||||
|
seatsTotal: 10,
|
||||||
|
licenseType: "subscription",
|
||||||
|
});
|
||||||
|
expect(result.value.expiryDate).toBeInstanceOf(Date);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts valid input with no expiryDate (perpetual license)", () => {
|
||||||
|
const result = validateCreateLicenseInput({
|
||||||
|
name: "Windows Server 2022",
|
||||||
|
vendor: "Microsoft",
|
||||||
|
seatsTotal: 5,
|
||||||
|
licenseType: "perpetual",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.value.expiryDate).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty name", () => {
|
||||||
|
const result = validateCreateLicenseInput({
|
||||||
|
name: "",
|
||||||
|
vendor: "Adobe",
|
||||||
|
seatsTotal: 10,
|
||||||
|
licenseType: "subscription",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/name/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty vendor", () => {
|
||||||
|
const result = validateCreateLicenseInput({
|
||||||
|
name: "Adobe Creative Cloud",
|
||||||
|
vendor: "",
|
||||||
|
seatsTotal: 10,
|
||||||
|
licenseType: "subscription",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/vendor/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a zero seatsTotal", () => {
|
||||||
|
const result = validateCreateLicenseInput({
|
||||||
|
name: "Adobe Creative Cloud",
|
||||||
|
vendor: "Adobe",
|
||||||
|
seatsTotal: 0,
|
||||||
|
licenseType: "subscription",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/seatsTotal/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a negative seatsTotal", () => {
|
||||||
|
const result = validateCreateLicenseInput({
|
||||||
|
name: "Adobe Creative Cloud",
|
||||||
|
vendor: "Adobe",
|
||||||
|
seatsTotal: -5,
|
||||||
|
licenseType: "subscription",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/seatsTotal/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-integer seatsTotal", () => {
|
||||||
|
const result = validateCreateLicenseInput({
|
||||||
|
name: "Adobe Creative Cloud",
|
||||||
|
vendor: "Adobe",
|
||||||
|
seatsTotal: 3.5,
|
||||||
|
licenseType: "subscription",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/seatsTotal/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid expiryDate", () => {
|
||||||
|
const result = validateCreateLicenseInput({
|
||||||
|
name: "Adobe Creative Cloud",
|
||||||
|
vendor: "Adobe",
|
||||||
|
seatsTotal: 10,
|
||||||
|
expiryDate: "not-a-date",
|
||||||
|
licenseType: "subscription",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/expiryDate/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid licenseType", () => {
|
||||||
|
const result = validateCreateLicenseInput({
|
||||||
|
name: "Adobe Creative Cloud",
|
||||||
|
vendor: "Adobe",
|
||||||
|
seatsTotal: 10,
|
||||||
|
licenseType: "trial",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/licenseType/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing licenseType", () => {
|
||||||
|
const result = validateCreateLicenseInput({
|
||||||
|
name: "Adobe Creative Cloud",
|
||||||
|
vendor: "Adobe",
|
||||||
|
seatsTotal: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/licenseType/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createLicense", () => {
|
||||||
|
it("creates a license with seatsUsed always 0, regardless of client input", async () => {
|
||||||
|
const result = validateCreateLicenseInput({
|
||||||
|
name: "Adobe Creative Cloud",
|
||||||
|
vendor: "Adobe",
|
||||||
|
seatsTotal: 10,
|
||||||
|
// @ts-expect-error -- seatsUsed is intentionally not part of
|
||||||
|
// CreateLicenseInput; this simulates a client trying to sneak it in.
|
||||||
|
seatsUsed: 999,
|
||||||
|
expiryDate: "2027-01-15",
|
||||||
|
licenseType: "subscription",
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
|
||||||
|
const record = await createLicense(result.value);
|
||||||
|
|
||||||
|
expect(record.seats_used).toBe(0);
|
||||||
|
expect(record).toMatchObject({
|
||||||
|
name: "Adobe Creative Cloud",
|
||||||
|
vendor: "Adobe",
|
||||||
|
seats_total: 10,
|
||||||
|
license_type: "subscription",
|
||||||
|
});
|
||||||
|
expect(typeof record.id).toBe("string");
|
||||||
|
expect(record.id.length).toBeGreaterThan(0);
|
||||||
|
expect(Number.isNaN(Date.parse(record.expiry_date as string))).toBe(false);
|
||||||
|
expect(Number.isNaN(Date.parse(record.created_at))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a perpetual license with no expiryDate", async () => {
|
||||||
|
const result = validateCreateLicenseInput({
|
||||||
|
name: "Windows Server 2022",
|
||||||
|
vendor: "Microsoft",
|
||||||
|
seatsTotal: 5,
|
||||||
|
licenseType: "perpetual",
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
|
||||||
|
const record = await createLicense(result.value);
|
||||||
|
|
||||||
|
expect(record.expiry_date).toBeNull();
|
||||||
|
expect(record.seats_used).toBe(0);
|
||||||
|
expect(record.license_type).toBe("perpetual");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listLicenses", () => {
|
||||||
|
it("lists created licenses, newest first", async () => {
|
||||||
|
const first = validateCreateLicenseInput({
|
||||||
|
name: "Windows Server 2022",
|
||||||
|
vendor: "Microsoft",
|
||||||
|
seatsTotal: 5,
|
||||||
|
licenseType: "perpetual",
|
||||||
|
});
|
||||||
|
expect(first.ok).toBe(true);
|
||||||
|
if (first.ok) await createLicense(first.value);
|
||||||
|
|
||||||
|
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
|
||||||
|
const second = validateCreateLicenseInput({
|
||||||
|
name: "Adobe Creative Cloud",
|
||||||
|
vendor: "Adobe",
|
||||||
|
seatsTotal: 10,
|
||||||
|
licenseType: "subscription",
|
||||||
|
});
|
||||||
|
expect(second.ok).toBe(true);
|
||||||
|
if (second.ok) await createLicense(second.value);
|
||||||
|
|
||||||
|
const records = await listLicenses();
|
||||||
|
|
||||||
|
expect(records).toHaveLength(2);
|
||||||
|
// newest first
|
||||||
|
expect(records[0]).toMatchObject({ name: "Adobe Creative Cloud", vendor: "Adobe" });
|
||||||
|
expect(records[1]).toMatchObject({ name: "Windows Server 2022", vendor: "Microsoft" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty list when there are no licenses", async () => {
|
||||||
|
const records = await listLicenses();
|
||||||
|
expect(records).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/licenses", () => {
|
||||||
|
it("creates a license with seatsUsed always 0 on valid input", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({
|
||||||
|
name: "Adobe Creative Cloud",
|
||||||
|
vendor: "Adobe",
|
||||||
|
seatsTotal: 10,
|
||||||
|
expiryDate: "2027-01-15",
|
||||||
|
licenseType: "subscription",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
name: "Adobe Creative Cloud",
|
||||||
|
vendor: "Adobe",
|
||||||
|
seats_total: 10,
|
||||||
|
seats_used: 0,
|
||||||
|
license_type: "subscription",
|
||||||
|
});
|
||||||
|
expect(typeof body.id).toBe("string");
|
||||||
|
expect(body.id.length).toBeGreaterThan(0);
|
||||||
|
expect(Number.isNaN(Date.parse(body.expiry_date))).toBe(false);
|
||||||
|
expect(Number.isNaN(Date.parse(body.created_at))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a perpetual license with no expiryDate", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({
|
||||||
|
name: "Windows Server 2022",
|
||||||
|
vendor: "Microsoft",
|
||||||
|
seatsTotal: 5,
|
||||||
|
licenseType: "perpetual",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.expiry_date).toBeNull();
|
||||||
|
expect(body.seats_used).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty name", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({ name: "", vendor: "Adobe", seatsTotal: 10, licenseType: "subscription" })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/name/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty vendor", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({ name: "Adobe Creative Cloud", vendor: "", seatsTotal: 10, licenseType: "subscription" })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/vendor/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a zero seatsTotal", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({ name: "Adobe Creative Cloud", vendor: "Adobe", seatsTotal: 0, licenseType: "subscription" })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/seatsTotal/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid expiryDate", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({
|
||||||
|
name: "Adobe Creative Cloud",
|
||||||
|
vendor: "Adobe",
|
||||||
|
seatsTotal: 10,
|
||||||
|
expiryDate: "not-a-date",
|
||||||
|
licenseType: "subscription",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/expiryDate/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid licenseType", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({ name: "Adobe Creative Cloud", vendor: "Adobe", seatsTotal: 10, licenseType: "trial" })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/licenseType/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid JSON body", async () => {
|
||||||
|
const req = new Request("http://localhost/api/licenses", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: "not json",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await POST(req);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/JSON/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-object JSON body", async () => {
|
||||||
|
// Note: arrays are `typeof "object"` in JS, so they pass the route's
|
||||||
|
// object check (same behavior as /api/assets) — use a JSON string
|
||||||
|
// primitive here to actually exercise the "not an object" branch.
|
||||||
|
const res = await POST(postRequest("not an object"));
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/JSON object/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/licenses", () => {
|
||||||
|
it("lists created licenses, newest first", async () => {
|
||||||
|
await POST(
|
||||||
|
postRequest({ name: "Windows Server 2022", vendor: "Microsoft", seatsTotal: 5, licenseType: "perpetual" })
|
||||||
|
);
|
||||||
|
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
await POST(
|
||||||
|
postRequest({ name: "Adobe Creative Cloud", vendor: "Adobe", seatsTotal: 10, licenseType: "subscription" })
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await GET();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
expect(Array.isArray(body)).toBe(true);
|
||||||
|
expect(body).toHaveLength(2);
|
||||||
|
|
||||||
|
// newest first
|
||||||
|
expect(body[0]).toMatchObject({ name: "Adobe Creative Cloud", vendor: "Adobe" });
|
||||||
|
expect(body[1]).toMatchObject({ name: "Windows Server 2022", vendor: "Microsoft" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty list when there are no licenses", async () => {
|
||||||
|
const res = await GET();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("auth gating on /api/licenses", () => {
|
||||||
|
// Mirrors tests/assets.test.ts's "auth gating" block — only checks
|
||||||
|
// session presence, per the flat permission model (no role/permission
|
||||||
|
// logic).
|
||||||
|
it("POST /api/licenses returns 401 without a session", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({ name: "Adobe Creative Cloud", vendor: "Adobe", seatsTotal: 10, licenseType: "subscription" })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toEqual({ error: "Unauthorized" });
|
||||||
|
|
||||||
|
// And it must not have created anything.
|
||||||
|
const remaining = await prisma.license.findMany();
|
||||||
|
expect(remaining).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/licenses returns 401 without a session", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await GET();
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toEqual({ error: "Unauthorized" });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user