fix: retrofit License.vendor to vendorId FK + createLicense error handling
License.vendor (free-text string) is now vendorId, a required FK to
Vendor, per docs/asset-license-management.md's "## Vendor (chốt
2026-08-02)" spec. Mirrors the Request.employeeId string->FK precedent:
Zod validates UUID format only, Prisma's own FK constraint (P2003) is
caught at insert time and translated into a clean 400 instead of a raw
500. No License data existed yet, so this is a pure column-type
migration, no backfill needed.
Also converts createLicense() to the {ok,value}|{ok,error} result
pattern used elsewhere (createRequest, createAssetItem) instead of
returning a bare LicenseRecord promise - closes the gap flagged in
CLAUDE.md now that License has its first FK-violation error case to
handle.
This commit is contained in:
+135
-39
@@ -34,10 +34,27 @@ function postRequest(body: unknown): Request {
|
||||
});
|
||||
}
|
||||
|
||||
// License.vendorId is a real FK to Vendor (see schema.prisma /
|
||||
// src/lib/licenses.ts) — tests that need a valid vendorId create a real
|
||||
// Vendor row first and use its id, rather than an arbitrary string. Mirrors
|
||||
// tests/requests.test.ts's createTestEmployee() helper.
|
||||
function createTestVendor(overrides: { name?: string } = {}) {
|
||||
return prisma.vendor.create({
|
||||
data: {
|
||||
name: overrides.name ?? "Test Vendor",
|
||||
contactEmail: "[email protected]",
|
||||
contactPhone: "0281234567",
|
||||
address: "123 Test Street",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// Each test starts from an empty table so listing/order assertions are
|
||||
// deterministic.
|
||||
// Each test starts from empty tables so listing/order assertions are
|
||||
// deterministic. licenses must be cleared before vendors since
|
||||
// licenses.vendor_id has an FK (ON DELETE RESTRICT) to vendors.
|
||||
await prisma.license.deleteMany();
|
||||
await prisma.vendor.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
|
||||
@@ -49,7 +66,7 @@ describe("validateCreateLicenseInput", () => {
|
||||
it("accepts valid input with an expiryDate", () => {
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendor: "Adobe",
|
||||
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||
seatsTotal: 10,
|
||||
expiryDate: "2027-01-15",
|
||||
licenseType: "subscription",
|
||||
@@ -59,7 +76,7 @@ describe("validateCreateLicenseInput", () => {
|
||||
if (result.ok) {
|
||||
expect(result.value).toMatchObject({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendor: "Adobe",
|
||||
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||
seatsTotal: 10,
|
||||
licenseType: "subscription",
|
||||
});
|
||||
@@ -70,7 +87,7 @@ describe("validateCreateLicenseInput", () => {
|
||||
it("accepts valid input with no expiryDate (perpetual license)", () => {
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Windows Server 2022",
|
||||
vendor: "Microsoft",
|
||||
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||
seatsTotal: 5,
|
||||
licenseType: "perpetual",
|
||||
});
|
||||
@@ -84,7 +101,7 @@ describe("validateCreateLicenseInput", () => {
|
||||
it("rejects an empty name", () => {
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "",
|
||||
vendor: "Adobe",
|
||||
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||
seatsTotal: 10,
|
||||
licenseType: "subscription",
|
||||
});
|
||||
@@ -95,24 +112,37 @@ describe("validateCreateLicenseInput", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an empty vendor", () => {
|
||||
it("rejects a vendorId that is not a valid UUID", () => {
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendor: "",
|
||||
vendorId: "not-a-uuid",
|
||||
seatsTotal: 10,
|
||||
licenseType: "subscription",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toMatch(/vendor/i);
|
||||
expect(result.error).toMatch(/vendorId/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a missing vendorId", () => {
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Adobe Creative Cloud",
|
||||
seatsTotal: 10,
|
||||
licenseType: "subscription",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toMatch(/vendorId/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a zero seatsTotal", () => {
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendor: "Adobe",
|
||||
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||
seatsTotal: 0,
|
||||
licenseType: "subscription",
|
||||
});
|
||||
@@ -126,7 +156,7 @@ describe("validateCreateLicenseInput", () => {
|
||||
it("rejects a negative seatsTotal", () => {
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendor: "Adobe",
|
||||
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||
seatsTotal: -5,
|
||||
licenseType: "subscription",
|
||||
});
|
||||
@@ -140,7 +170,7 @@ describe("validateCreateLicenseInput", () => {
|
||||
it("rejects a non-integer seatsTotal", () => {
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendor: "Adobe",
|
||||
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||
seatsTotal: 3.5,
|
||||
licenseType: "subscription",
|
||||
});
|
||||
@@ -154,7 +184,7 @@ describe("validateCreateLicenseInput", () => {
|
||||
it("rejects an invalid expiryDate", () => {
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendor: "Adobe",
|
||||
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||
seatsTotal: 10,
|
||||
expiryDate: "not-a-date",
|
||||
licenseType: "subscription",
|
||||
@@ -169,7 +199,7 @@ describe("validateCreateLicenseInput", () => {
|
||||
it("rejects an invalid licenseType", () => {
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendor: "Adobe",
|
||||
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||
seatsTotal: 10,
|
||||
licenseType: "trial",
|
||||
});
|
||||
@@ -183,7 +213,7 @@ describe("validateCreateLicenseInput", () => {
|
||||
it("rejects a missing licenseType", () => {
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendor: "Adobe",
|
||||
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||
seatsTotal: 10,
|
||||
});
|
||||
|
||||
@@ -196,9 +226,10 @@ describe("validateCreateLicenseInput", () => {
|
||||
|
||||
describe("createLicense", () => {
|
||||
it("creates a license with seatsUsed always 0, regardless of client input", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendor: "Adobe",
|
||||
vendorId: vendor.id,
|
||||
seatsTotal: 10,
|
||||
// @ts-expect-error -- seatsUsed is intentionally not part of
|
||||
// CreateLicenseInput; this simulates a client trying to sneak it in.
|
||||
@@ -209,12 +240,15 @@ describe("createLicense", () => {
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
const record = await createLicense(result.value);
|
||||
const created = await createLicense(result.value);
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
const record = created.value;
|
||||
|
||||
expect(record.seats_used).toBe(0);
|
||||
expect(record).toMatchObject({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendor: "Adobe",
|
||||
vendor_id: vendor.id,
|
||||
seats_total: 10,
|
||||
license_type: "subscription",
|
||||
});
|
||||
@@ -225,28 +259,55 @@ describe("createLicense", () => {
|
||||
});
|
||||
|
||||
it("creates a perpetual license with no expiryDate", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Windows Server 2022",
|
||||
vendor: "Microsoft",
|
||||
vendorId: vendor.id,
|
||||
seatsTotal: 5,
|
||||
licenseType: "perpetual",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
const record = await createLicense(result.value);
|
||||
const created = await createLicense(result.value);
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
const record = created.value;
|
||||
|
||||
expect(record.expiry_date).toBeNull();
|
||||
expect(record.seats_used).toBe(0);
|
||||
expect(record.license_type).toBe("perpetual");
|
||||
});
|
||||
|
||||
it("returns a clean error when vendorId doesn't reference an existing vendor", async () => {
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendorId: "00000000-0000-0000-0000-000000000000",
|
||||
seatsTotal: 10,
|
||||
licenseType: "subscription",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
const created = await createLicense(result.value);
|
||||
expect(created.ok).toBe(false);
|
||||
if (created.ok) return;
|
||||
expect(created.error).toMatch(/vendorId/i);
|
||||
expect(created.error).toMatch(/does not reference an existing vendor/i);
|
||||
|
||||
const remaining = await prisma.license.findMany();
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listLicenses", () => {
|
||||
it("lists created licenses, newest first", async () => {
|
||||
const vendor1 = await createTestVendor({ name: "Microsoft VN" });
|
||||
const vendor2 = await createTestVendor({ name: "Adobe VN" });
|
||||
|
||||
const first = validateCreateLicenseInput({
|
||||
name: "Windows Server 2022",
|
||||
vendor: "Microsoft",
|
||||
vendorId: vendor1.id,
|
||||
seatsTotal: 5,
|
||||
licenseType: "perpetual",
|
||||
});
|
||||
@@ -258,7 +319,7 @@ describe("listLicenses", () => {
|
||||
|
||||
const second = validateCreateLicenseInput({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendor: "Adobe",
|
||||
vendorId: vendor2.id,
|
||||
seatsTotal: 10,
|
||||
licenseType: "subscription",
|
||||
});
|
||||
@@ -269,8 +330,8 @@ describe("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" });
|
||||
expect(records[0]).toMatchObject({ name: "Adobe Creative Cloud", vendor_id: vendor2.id });
|
||||
expect(records[1]).toMatchObject({ name: "Windows Server 2022", vendor_id: vendor1.id });
|
||||
});
|
||||
|
||||
it("returns an empty list when there are no licenses", async () => {
|
||||
@@ -281,10 +342,11 @@ describe("listLicenses", () => {
|
||||
|
||||
describe("POST /api/licenses", () => {
|
||||
it("creates a license with seatsUsed always 0 on valid input", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendor: "Adobe",
|
||||
vendorId: vendor.id,
|
||||
seatsTotal: 10,
|
||||
expiryDate: "2027-01-15",
|
||||
licenseType: "subscription",
|
||||
@@ -296,7 +358,7 @@ describe("POST /api/licenses", () => {
|
||||
|
||||
expect(body).toMatchObject({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendor: "Adobe",
|
||||
vendor_id: vendor.id,
|
||||
seats_total: 10,
|
||||
seats_used: 0,
|
||||
license_type: "subscription",
|
||||
@@ -308,10 +370,11 @@ describe("POST /api/licenses", () => {
|
||||
});
|
||||
|
||||
it("creates a perpetual license with no expiryDate", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
name: "Windows Server 2022",
|
||||
vendor: "Microsoft",
|
||||
vendorId: vendor.id,
|
||||
seatsTotal: 5,
|
||||
licenseType: "perpetual",
|
||||
})
|
||||
@@ -324,8 +387,9 @@ describe("POST /api/licenses", () => {
|
||||
});
|
||||
|
||||
it("rejects an empty name", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const res = await POST(
|
||||
postRequest({ name: "", vendor: "Adobe", seatsTotal: 10, licenseType: "subscription" })
|
||||
postRequest({ name: "", vendorId: vendor.id, seatsTotal: 10, licenseType: "subscription" })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
@@ -333,19 +397,45 @@ describe("POST /api/licenses", () => {
|
||||
expect(body.error).toMatch(/name/i);
|
||||
});
|
||||
|
||||
it("rejects an empty vendor", async () => {
|
||||
it("rejects a vendorId that is not a valid UUID", async () => {
|
||||
const res = await POST(
|
||||
postRequest({ name: "Adobe Creative Cloud", vendor: "", seatsTotal: 10, licenseType: "subscription" })
|
||||
postRequest({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendorId: "not-a-uuid",
|
||||
seatsTotal: 10,
|
||||
licenseType: "subscription",
|
||||
})
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/vendor/i);
|
||||
expect(body.error).toMatch(/vendorId/i);
|
||||
});
|
||||
|
||||
it("rejects a vendorId that is a well-formed UUID but doesn't reference an existing vendor", async () => {
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendorId: "00000000-0000-0000-0000-000000000000",
|
||||
seatsTotal: 10,
|
||||
licenseType: "subscription",
|
||||
})
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/vendorId/i);
|
||||
expect(body.error).toMatch(/does not reference an existing vendor/i);
|
||||
|
||||
// And it must not have created anything.
|
||||
const remaining = await prisma.license.findMany();
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a zero seatsTotal", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const res = await POST(
|
||||
postRequest({ name: "Adobe Creative Cloud", vendor: "Adobe", seatsTotal: 0, licenseType: "subscription" })
|
||||
postRequest({ name: "Adobe Creative Cloud", vendorId: vendor.id, seatsTotal: 0, licenseType: "subscription" })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
@@ -354,10 +444,11 @@ describe("POST /api/licenses", () => {
|
||||
});
|
||||
|
||||
it("rejects an invalid expiryDate", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendor: "Adobe",
|
||||
vendorId: vendor.id,
|
||||
seatsTotal: 10,
|
||||
expiryDate: "not-a-date",
|
||||
licenseType: "subscription",
|
||||
@@ -370,8 +461,9 @@ describe("POST /api/licenses", () => {
|
||||
});
|
||||
|
||||
it("rejects an invalid licenseType", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const res = await POST(
|
||||
postRequest({ name: "Adobe Creative Cloud", vendor: "Adobe", seatsTotal: 10, licenseType: "trial" })
|
||||
postRequest({ name: "Adobe Creative Cloud", vendorId: vendor.id, seatsTotal: 10, licenseType: "trial" })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
@@ -406,13 +498,16 @@ describe("POST /api/licenses", () => {
|
||||
|
||||
describe("GET /api/licenses", () => {
|
||||
it("lists created licenses, newest first", async () => {
|
||||
const vendor1 = await createTestVendor({ name: "Microsoft VN" });
|
||||
const vendor2 = await createTestVendor({ name: "Adobe VN" });
|
||||
|
||||
await POST(
|
||||
postRequest({ name: "Windows Server 2022", vendor: "Microsoft", seatsTotal: 5, licenseType: "perpetual" })
|
||||
postRequest({ name: "Windows Server 2022", vendorId: vendor1.id, 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" })
|
||||
postRequest({ name: "Adobe Creative Cloud", vendorId: vendor2.id, seatsTotal: 10, licenseType: "subscription" })
|
||||
);
|
||||
|
||||
const res = await GET();
|
||||
@@ -423,8 +518,8 @@ describe("GET /api/licenses", () => {
|
||||
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" });
|
||||
expect(body[0]).toMatchObject({ name: "Adobe Creative Cloud", vendor_id: vendor2.id });
|
||||
expect(body[1]).toMatchObject({ name: "Windows Server 2022", vendor_id: vendor1.id });
|
||||
});
|
||||
|
||||
it("returns an empty list when there are no licenses", async () => {
|
||||
@@ -440,10 +535,11 @@ describe("auth gating on /api/licenses", () => {
|
||||
// session presence, per the flat permission model (no role/permission
|
||||
// logic).
|
||||
it("POST /api/licenses returns 401 without a session", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
mockedAuth.mockResolvedValue(null);
|
||||
|
||||
const res = await POST(
|
||||
postRequest({ name: "Adobe Creative Cloud", vendor: "Adobe", seatsTotal: 10, licenseType: "subscription" })
|
||||
postRequest({ name: "Adobe Creative Cloud", vendorId: vendor.id, seatsTotal: 10, licenseType: "subscription" })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
|
||||
Reference in New Issue
Block a user