Compare commits
2
Commits
9a4a3fede3
...
7b5ab70596
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b5ab70596 | ||
|
|
9d99ebb425 |
@@ -0,0 +1,13 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `vendor` on the `licenses` table. All the data in the column will be lost.
|
||||||
|
- Added the required column `vendor_id` to the `licenses` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "licenses" DROP COLUMN "vendor",
|
||||||
|
ADD COLUMN "vendor_id" UUID NOT NULL;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "licenses" ADD CONSTRAINT "licenses_vendor_id_fkey" FOREIGN KEY ("vendor_id") REFERENCES "vendors"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
+22
-18
@@ -102,17 +102,20 @@ enum LicenseType {
|
|||||||
|
|
||||||
// Software license inventory — see docs/asset-license-management.md's "Data
|
// Software license inventory — see docs/asset-license-management.md's "Data
|
||||||
// model (sơ bộ)" table (tên, vendor, tổng seat, seat đã dùng, ngày hết hạn,
|
// model (sơ bộ)" table (tên, vendor, tổng seat, seat đã dùng, ngày hết hạn,
|
||||||
// loại). `vendor` is deliberately a plain String for now, not a separate
|
// loại). `vendorId` was a free-text `vendor` String until the "## Vendor
|
||||||
// Vendor entity — no Vendor entity exists yet, same "plain string for now"
|
// (chốt 2026-08-02)" retrofit — same precedent as Request.employeeId
|
||||||
// precedent as Employee.department. `seatsUsed` is a stored counter
|
// (string -> FK once Employee existed). No production `License` data
|
||||||
// (defaults to 0 at creation), not a computed value — it will only ever be
|
// existed at the time of this change, so this was a plain column-type
|
||||||
// set to 0 here since Assignment (the thing that would consume seats)
|
// migration, not a backfill. `seatsUsed` is a stored counter (defaults to 0
|
||||||
// doesn't exist yet. `expiryDate` is nullable: perpetual licenses never
|
// at creation), not a computed value — it will only ever be set to 0 here
|
||||||
// expire, only subscription licenses usually have one.
|
// since Assignment (the thing that would consume seats) doesn't exist yet.
|
||||||
|
// `expiryDate` is nullable: perpetual licenses never expire, only
|
||||||
|
// subscription licenses usually have one.
|
||||||
model License {
|
model License {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
name String
|
name String
|
||||||
vendor String
|
vendorId String @map("vendor_id") @db.Uuid
|
||||||
|
vendor Vendor @relation(fields: [vendorId], references: [id])
|
||||||
seatsTotal Int @map("seats_total")
|
seatsTotal Int @map("seats_total")
|
||||||
seatsUsed Int @default(0) @map("seats_used")
|
seatsUsed Int @default(0) @map("seats_used")
|
||||||
expiryDate DateTime? @map("expiry_date") @db.Date
|
expiryDate DateTime? @map("expiry_date") @db.Date
|
||||||
@@ -126,20 +129,21 @@ model License {
|
|||||||
// (chốt 2026-08-02)". Deliberately independent of AssetModel.manufacturer
|
// (chốt 2026-08-02)". Deliberately independent of AssetModel.manufacturer
|
||||||
// (OEM, e.g. "Dell") — Vendor is the reseller/distributor actually bought
|
// (OEM, e.g. "Dell") — Vendor is the reseller/distributor actually bought
|
||||||
// from (e.g. "FPT Trading"), a different concept that isn't always the same
|
// from (e.g. "FPT Trading"), a different concept that isn't always the same
|
||||||
// company. No FKs on this entity yet: it's the starting point for later
|
// company. `licenses` is the back-relation from the License.vendor ->
|
||||||
// FKs from PurchaseOrder/License (License.vendor string -> License.vendorId
|
// License.vendorId retrofit (same doc section) — PurchaseOrder is a future
|
||||||
// FK retrofit is noted in the doc as a separate follow-up, not done here).
|
// FK from this entity, not built yet. taxCode/notes are nullable — not
|
||||||
// taxCode/notes are nullable — not every vendor record has a known VN tax
|
// every vendor record has a known VN tax code or extra notes at creation
|
||||||
// code or extra notes at creation time.
|
// time.
|
||||||
model Vendor {
|
model Vendor {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
name String
|
name String
|
||||||
contactEmail String @map("contact_email")
|
contactEmail String @map("contact_email")
|
||||||
contactPhone String @map("contact_phone")
|
contactPhone String @map("contact_phone")
|
||||||
address String
|
address String
|
||||||
taxCode String? @map("tax_code")
|
taxCode String? @map("tax_code")
|
||||||
notes String?
|
notes String?
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
licenses License[]
|
||||||
|
|
||||||
@@map("vendors")
|
@@map("vendors")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ export async function POST(request: Request): Promise<NextResponse> {
|
|||||||
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, vendor, seatsTotal, expiryDate, licenseType } = body as Record<string, unknown>;
|
const { name, vendorId, seatsTotal, expiryDate, licenseType } = body as Record<string, unknown>;
|
||||||
const validation = validateCreateLicenseInput({
|
const validation = validateCreateLicenseInput({
|
||||||
name,
|
name,
|
||||||
vendor,
|
vendorId,
|
||||||
seatsTotal,
|
seatsTotal,
|
||||||
expiryDate,
|
expiryDate,
|
||||||
licenseType,
|
licenseType,
|
||||||
@@ -36,7 +36,10 @@ export async function POST(request: Request): Promise<NextResponse> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const created = await createLicense(validation.value);
|
const created = await createLicense(validation.value);
|
||||||
return NextResponse.json(created, { status: 201 });
|
if (!created.ok) {
|
||||||
|
return NextResponse.json({ error: created.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(created.value, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(): Promise<NextResponse> {
|
export async function GET(): Promise<NextResponse> {
|
||||||
|
|||||||
+48
-27
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
export type LicenseType = "perpetual" | "subscription";
|
export type LicenseType = "perpetual" | "subscription";
|
||||||
@@ -6,7 +7,7 @@ export type LicenseType = "perpetual" | "subscription";
|
|||||||
export interface LicenseRecord {
|
export interface LicenseRecord {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
vendor: string;
|
vendor_id: string;
|
||||||
seats_total: number;
|
seats_total: number;
|
||||||
seats_used: number;
|
seats_used: number;
|
||||||
expiry_date: string | null;
|
expiry_date: string | null;
|
||||||
@@ -16,7 +17,7 @@ export interface LicenseRecord {
|
|||||||
|
|
||||||
export interface CreateLicenseInput {
|
export interface CreateLicenseInput {
|
||||||
name: string;
|
name: string;
|
||||||
vendor: string;
|
vendorId: string;
|
||||||
seatsTotal: number;
|
seatsTotal: number;
|
||||||
expiryDate?: Date | null;
|
expiryDate?: Date | null;
|
||||||
licenseType: LicenseType;
|
licenseType: LicenseType;
|
||||||
@@ -46,7 +47,14 @@ function dateString(message: string) {
|
|||||||
|
|
||||||
const createLicenseSchema = z.object({
|
const createLicenseSchema = z.object({
|
||||||
name: nonEmptyString("name is required and must be a non-empty string"),
|
name: nonEmptyString("name is required and must be a non-empty string"),
|
||||||
vendor: nonEmptyString("vendor is required and must be a non-empty string"),
|
// Now a real FK to Vendor (see schema.prisma) — only a UUID *format*
|
||||||
|
// check here — whether it references a real vendor is left to Prisma's
|
||||||
|
// own FK constraint at insert time (see createLicense()'s P2003 handling
|
||||||
|
// below), rather than an extra pre-check query. Mirrors the same pattern
|
||||||
|
// used for Request.employeeId in src/lib/requests.ts.
|
||||||
|
vendorId: z
|
||||||
|
.string({ required_error: "vendorId is required and must be a valid UUID string" })
|
||||||
|
.uuid("vendorId is required and must be a valid UUID string"),
|
||||||
seatsTotal: z
|
seatsTotal: z
|
||||||
.number({ required_error: "seatsTotal is required and must be a positive integer", invalid_type_error: "seatsTotal is required and must be a positive integer" })
|
.number({ required_error: "seatsTotal is required and must be a positive integer", invalid_type_error: "seatsTotal is required and must be a positive integer" })
|
||||||
.int({ message: "seatsTotal is required and must be a positive integer" })
|
.int({ message: "seatsTotal is required and must be a positive integer" })
|
||||||
@@ -60,17 +68,17 @@ const createLicenseSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates raw (untyped) license-creation input. name/vendor must be
|
* Validates raw (untyped) license-creation input. name must be a non-empty
|
||||||
* non-empty strings, seatsTotal must be a positive integer, expiryDate is
|
* string, vendorId must be a valid UUID string, seatsTotal must be a
|
||||||
* optional/nullable but must be a valid date string if present, licenseType
|
* positive integer, expiryDate is optional/nullable but must be a valid
|
||||||
* must be one of the two enum values. seatsUsed is not accepted here — new
|
* date string if present, licenseType must be one of the two enum values.
|
||||||
* licenses always start at 0 (same pattern as AssetItem always starting
|
* seatsUsed is not accepted here — new licenses always start at 0 (same
|
||||||
* "in_stock"), seats are only ever consumed via other operations (once
|
* pattern as AssetItem always starting "in_stock"), seats are only ever
|
||||||
* Assignment exists), not at creation.
|
* consumed via other operations (once Assignment exists), not at creation.
|
||||||
*/
|
*/
|
||||||
export function validateCreateLicenseInput(input: {
|
export function validateCreateLicenseInput(input: {
|
||||||
name?: unknown;
|
name?: unknown;
|
||||||
vendor?: unknown;
|
vendorId?: unknown;
|
||||||
seatsTotal?: unknown;
|
seatsTotal?: unknown;
|
||||||
expiryDate?: unknown;
|
expiryDate?: unknown;
|
||||||
licenseType?: unknown;
|
licenseType?: unknown;
|
||||||
@@ -86,7 +94,7 @@ export function validateCreateLicenseInput(input: {
|
|||||||
function toRecord(row: {
|
function toRecord(row: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
vendor: string;
|
vendorId: string;
|
||||||
seatsTotal: number;
|
seatsTotal: number;
|
||||||
seatsUsed: number;
|
seatsUsed: number;
|
||||||
expiryDate: Date | null;
|
expiryDate: Date | null;
|
||||||
@@ -96,7 +104,7 @@ function toRecord(row: {
|
|||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
vendor: row.vendor,
|
vendor_id: row.vendorId,
|
||||||
seats_total: row.seatsTotal,
|
seats_total: row.seatsTotal,
|
||||||
seats_used: row.seatsUsed,
|
seats_used: row.seatsUsed,
|
||||||
expiry_date: row.expiryDate ? row.expiryDate.toISOString() : null,
|
expiry_date: row.expiryDate ? row.expiryDate.toISOString() : null,
|
||||||
@@ -105,22 +113,35 @@ function toRecord(row: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CreateLicenseResult =
|
||||||
|
| { ok: true; value: LicenseRecord }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a License. No unique constraint exists on `name` (unlike
|
* Creates a License. If vendorId doesn't reference an existing Vendor,
|
||||||
* AssetItem.serialNumber), so unlike createAssetItem() there's no known
|
* Prisma raises a known error (code P2003, FK violation) — caught here and
|
||||||
* Prisma error code to catch/translate here.
|
* translated into the same `{ok:false,error}` shape as input validation
|
||||||
|
* failures, rather than letting a raw Prisma error leak to the API layer.
|
||||||
|
* Mirrors src/lib/requests.ts's createRequest() pattern for employeeId.
|
||||||
*/
|
*/
|
||||||
export async function createLicense(input: CreateLicenseInput): Promise<LicenseRecord> {
|
export async function createLicense(input: CreateLicenseInput): Promise<CreateLicenseResult> {
|
||||||
const created = await prisma.license.create({
|
try {
|
||||||
data: {
|
const created = await prisma.license.create({
|
||||||
name: input.name,
|
data: {
|
||||||
vendor: input.vendor,
|
name: input.name,
|
||||||
seatsTotal: input.seatsTotal,
|
vendorId: input.vendorId,
|
||||||
expiryDate: input.expiryDate ?? null,
|
seatsTotal: input.seatsTotal,
|
||||||
licenseType: input.licenseType,
|
expiryDate: input.expiryDate ?? null,
|
||||||
},
|
licenseType: input.licenseType,
|
||||||
});
|
},
|
||||||
return toRecord(created);
|
});
|
||||||
|
return { ok: true, value: toRecord(created) };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
|
||||||
|
return { ok: false, error: "vendorId does not reference an existing vendor" };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listLicenses(): Promise<LicenseRecord[]> {
|
export async function listLicenses(): Promise<LicenseRecord[]> {
|
||||||
|
|||||||
+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 () => {
|
beforeEach(async () => {
|
||||||
// Each test starts from an empty table so listing/order assertions are
|
// Each test starts from empty tables so listing/order assertions are
|
||||||
// deterministic.
|
// 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.license.deleteMany();
|
||||||
|
await prisma.vendor.deleteMany();
|
||||||
// Default to a logged-in session — the lib-level tests below don't go
|
// 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
|
// 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
|
// route-level tests exercise business logic, not auth gating (that's
|
||||||
@@ -49,7 +66,7 @@ describe("validateCreateLicenseInput", () => {
|
|||||||
it("accepts valid input with an expiryDate", () => {
|
it("accepts valid input with an expiryDate", () => {
|
||||||
const result = validateCreateLicenseInput({
|
const result = validateCreateLicenseInput({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor: "Adobe",
|
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
expiryDate: "2027-01-15",
|
expiryDate: "2027-01-15",
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
@@ -59,7 +76,7 @@ describe("validateCreateLicenseInput", () => {
|
|||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
expect(result.value).toMatchObject({
|
expect(result.value).toMatchObject({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor: "Adobe",
|
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
});
|
});
|
||||||
@@ -70,7 +87,7 @@ describe("validateCreateLicenseInput", () => {
|
|||||||
it("accepts valid input with no expiryDate (perpetual license)", () => {
|
it("accepts valid input with no expiryDate (perpetual license)", () => {
|
||||||
const result = validateCreateLicenseInput({
|
const result = validateCreateLicenseInput({
|
||||||
name: "Windows Server 2022",
|
name: "Windows Server 2022",
|
||||||
vendor: "Microsoft",
|
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||||
seatsTotal: 5,
|
seatsTotal: 5,
|
||||||
licenseType: "perpetual",
|
licenseType: "perpetual",
|
||||||
});
|
});
|
||||||
@@ -84,7 +101,7 @@ describe("validateCreateLicenseInput", () => {
|
|||||||
it("rejects an empty name", () => {
|
it("rejects an empty name", () => {
|
||||||
const result = validateCreateLicenseInput({
|
const result = validateCreateLicenseInput({
|
||||||
name: "",
|
name: "",
|
||||||
vendor: "Adobe",
|
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
licenseType: "subscription",
|
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({
|
const result = validateCreateLicenseInput({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor: "",
|
vendorId: "not-a-uuid",
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
if (!result.ok) {
|
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", () => {
|
it("rejects a zero seatsTotal", () => {
|
||||||
const result = validateCreateLicenseInput({
|
const result = validateCreateLicenseInput({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor: "Adobe",
|
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||||
seatsTotal: 0,
|
seatsTotal: 0,
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
});
|
});
|
||||||
@@ -126,7 +156,7 @@ describe("validateCreateLicenseInput", () => {
|
|||||||
it("rejects a negative seatsTotal", () => {
|
it("rejects a negative seatsTotal", () => {
|
||||||
const result = validateCreateLicenseInput({
|
const result = validateCreateLicenseInput({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor: "Adobe",
|
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||||
seatsTotal: -5,
|
seatsTotal: -5,
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
});
|
});
|
||||||
@@ -140,7 +170,7 @@ describe("validateCreateLicenseInput", () => {
|
|||||||
it("rejects a non-integer seatsTotal", () => {
|
it("rejects a non-integer seatsTotal", () => {
|
||||||
const result = validateCreateLicenseInput({
|
const result = validateCreateLicenseInput({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor: "Adobe",
|
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||||
seatsTotal: 3.5,
|
seatsTotal: 3.5,
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
});
|
});
|
||||||
@@ -154,7 +184,7 @@ describe("validateCreateLicenseInput", () => {
|
|||||||
it("rejects an invalid expiryDate", () => {
|
it("rejects an invalid expiryDate", () => {
|
||||||
const result = validateCreateLicenseInput({
|
const result = validateCreateLicenseInput({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor: "Adobe",
|
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
expiryDate: "not-a-date",
|
expiryDate: "not-a-date",
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
@@ -169,7 +199,7 @@ describe("validateCreateLicenseInput", () => {
|
|||||||
it("rejects an invalid licenseType", () => {
|
it("rejects an invalid licenseType", () => {
|
||||||
const result = validateCreateLicenseInput({
|
const result = validateCreateLicenseInput({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor: "Adobe",
|
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
licenseType: "trial",
|
licenseType: "trial",
|
||||||
});
|
});
|
||||||
@@ -183,7 +213,7 @@ describe("validateCreateLicenseInput", () => {
|
|||||||
it("rejects a missing licenseType", () => {
|
it("rejects a missing licenseType", () => {
|
||||||
const result = validateCreateLicenseInput({
|
const result = validateCreateLicenseInput({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor: "Adobe",
|
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -196,9 +226,10 @@ describe("validateCreateLicenseInput", () => {
|
|||||||
|
|
||||||
describe("createLicense", () => {
|
describe("createLicense", () => {
|
||||||
it("creates a license with seatsUsed always 0, regardless of client input", async () => {
|
it("creates a license with seatsUsed always 0, regardless of client input", async () => {
|
||||||
|
const vendor = await createTestVendor();
|
||||||
const result = validateCreateLicenseInput({
|
const result = validateCreateLicenseInput({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor: "Adobe",
|
vendorId: vendor.id,
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
// @ts-expect-error -- seatsUsed is intentionally not part of
|
// @ts-expect-error -- seatsUsed is intentionally not part of
|
||||||
// CreateLicenseInput; this simulates a client trying to sneak it in.
|
// CreateLicenseInput; this simulates a client trying to sneak it in.
|
||||||
@@ -209,12 +240,15 @@ describe("createLicense", () => {
|
|||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (!result.ok) return;
|
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.seats_used).toBe(0);
|
||||||
expect(record).toMatchObject({
|
expect(record).toMatchObject({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor: "Adobe",
|
vendor_id: vendor.id,
|
||||||
seats_total: 10,
|
seats_total: 10,
|
||||||
license_type: "subscription",
|
license_type: "subscription",
|
||||||
});
|
});
|
||||||
@@ -225,28 +259,55 @@ describe("createLicense", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("creates a perpetual license with no expiryDate", async () => {
|
it("creates a perpetual license with no expiryDate", async () => {
|
||||||
|
const vendor = await createTestVendor();
|
||||||
const result = validateCreateLicenseInput({
|
const result = validateCreateLicenseInput({
|
||||||
name: "Windows Server 2022",
|
name: "Windows Server 2022",
|
||||||
vendor: "Microsoft",
|
vendorId: vendor.id,
|
||||||
seatsTotal: 5,
|
seatsTotal: 5,
|
||||||
licenseType: "perpetual",
|
licenseType: "perpetual",
|
||||||
});
|
});
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (!result.ok) return;
|
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.expiry_date).toBeNull();
|
||||||
expect(record.seats_used).toBe(0);
|
expect(record.seats_used).toBe(0);
|
||||||
expect(record.license_type).toBe("perpetual");
|
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", () => {
|
describe("listLicenses", () => {
|
||||||
it("lists created licenses, newest first", async () => {
|
it("lists created licenses, newest first", async () => {
|
||||||
|
const vendor1 = await createTestVendor({ name: "Microsoft VN" });
|
||||||
|
const vendor2 = await createTestVendor({ name: "Adobe VN" });
|
||||||
|
|
||||||
const first = validateCreateLicenseInput({
|
const first = validateCreateLicenseInput({
|
||||||
name: "Windows Server 2022",
|
name: "Windows Server 2022",
|
||||||
vendor: "Microsoft",
|
vendorId: vendor1.id,
|
||||||
seatsTotal: 5,
|
seatsTotal: 5,
|
||||||
licenseType: "perpetual",
|
licenseType: "perpetual",
|
||||||
});
|
});
|
||||||
@@ -258,7 +319,7 @@ describe("listLicenses", () => {
|
|||||||
|
|
||||||
const second = validateCreateLicenseInput({
|
const second = validateCreateLicenseInput({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor: "Adobe",
|
vendorId: vendor2.id,
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
});
|
});
|
||||||
@@ -269,8 +330,8 @@ describe("listLicenses", () => {
|
|||||||
|
|
||||||
expect(records).toHaveLength(2);
|
expect(records).toHaveLength(2);
|
||||||
// newest first
|
// newest first
|
||||||
expect(records[0]).toMatchObject({ name: "Adobe Creative Cloud", vendor: "Adobe" });
|
expect(records[0]).toMatchObject({ name: "Adobe Creative Cloud", vendor_id: vendor2.id });
|
||||||
expect(records[1]).toMatchObject({ name: "Windows Server 2022", vendor: "Microsoft" });
|
expect(records[1]).toMatchObject({ name: "Windows Server 2022", vendor_id: vendor1.id });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns an empty list when there are no licenses", async () => {
|
it("returns an empty list when there are no licenses", async () => {
|
||||||
@@ -281,10 +342,11 @@ describe("listLicenses", () => {
|
|||||||
|
|
||||||
describe("POST /api/licenses", () => {
|
describe("POST /api/licenses", () => {
|
||||||
it("creates a license with seatsUsed always 0 on valid input", async () => {
|
it("creates a license with seatsUsed always 0 on valid input", async () => {
|
||||||
|
const vendor = await createTestVendor();
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
postRequest({
|
postRequest({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor: "Adobe",
|
vendorId: vendor.id,
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
expiryDate: "2027-01-15",
|
expiryDate: "2027-01-15",
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
@@ -296,7 +358,7 @@ describe("POST /api/licenses", () => {
|
|||||||
|
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor: "Adobe",
|
vendor_id: vendor.id,
|
||||||
seats_total: 10,
|
seats_total: 10,
|
||||||
seats_used: 0,
|
seats_used: 0,
|
||||||
license_type: "subscription",
|
license_type: "subscription",
|
||||||
@@ -308,10 +370,11 @@ describe("POST /api/licenses", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("creates a perpetual license with no expiryDate", async () => {
|
it("creates a perpetual license with no expiryDate", async () => {
|
||||||
|
const vendor = await createTestVendor();
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
postRequest({
|
postRequest({
|
||||||
name: "Windows Server 2022",
|
name: "Windows Server 2022",
|
||||||
vendor: "Microsoft",
|
vendorId: vendor.id,
|
||||||
seatsTotal: 5,
|
seatsTotal: 5,
|
||||||
licenseType: "perpetual",
|
licenseType: "perpetual",
|
||||||
})
|
})
|
||||||
@@ -324,8 +387,9 @@ describe("POST /api/licenses", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an empty name", async () => {
|
it("rejects an empty name", async () => {
|
||||||
|
const vendor = await createTestVendor();
|
||||||
const res = await POST(
|
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);
|
expect(res.status).toBe(400);
|
||||||
@@ -333,19 +397,45 @@ describe("POST /api/licenses", () => {
|
|||||||
expect(body.error).toMatch(/name/i);
|
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(
|
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);
|
expect(res.status).toBe(400);
|
||||||
const body = await res.json();
|
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 () => {
|
it("rejects a zero seatsTotal", async () => {
|
||||||
|
const vendor = await createTestVendor();
|
||||||
const res = await POST(
|
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);
|
expect(res.status).toBe(400);
|
||||||
@@ -354,10 +444,11 @@ describe("POST /api/licenses", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an invalid expiryDate", async () => {
|
it("rejects an invalid expiryDate", async () => {
|
||||||
|
const vendor = await createTestVendor();
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
postRequest({
|
postRequest({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor: "Adobe",
|
vendorId: vendor.id,
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
expiryDate: "not-a-date",
|
expiryDate: "not-a-date",
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
@@ -370,8 +461,9 @@ describe("POST /api/licenses", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an invalid licenseType", async () => {
|
it("rejects an invalid licenseType", async () => {
|
||||||
|
const vendor = await createTestVendor();
|
||||||
const res = await POST(
|
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);
|
expect(res.status).toBe(400);
|
||||||
@@ -406,13 +498,16 @@ describe("POST /api/licenses", () => {
|
|||||||
|
|
||||||
describe("GET /api/licenses", () => {
|
describe("GET /api/licenses", () => {
|
||||||
it("lists created licenses, newest first", async () => {
|
it("lists created licenses, newest first", async () => {
|
||||||
|
const vendor1 = await createTestVendor({ name: "Microsoft VN" });
|
||||||
|
const vendor2 = await createTestVendor({ name: "Adobe VN" });
|
||||||
|
|
||||||
await POST(
|
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.
|
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
|
||||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
await POST(
|
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();
|
const res = await GET();
|
||||||
@@ -423,8 +518,8 @@ describe("GET /api/licenses", () => {
|
|||||||
expect(body).toHaveLength(2);
|
expect(body).toHaveLength(2);
|
||||||
|
|
||||||
// newest first
|
// newest first
|
||||||
expect(body[0]).toMatchObject({ name: "Adobe Creative Cloud", vendor: "Adobe" });
|
expect(body[0]).toMatchObject({ name: "Adobe Creative Cloud", vendor_id: vendor2.id });
|
||||||
expect(body[1]).toMatchObject({ name: "Windows Server 2022", vendor: "Microsoft" });
|
expect(body[1]).toMatchObject({ name: "Windows Server 2022", vendor_id: vendor1.id });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns an empty list when there are no licenses", async () => {
|
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
|
// session presence, per the flat permission model (no role/permission
|
||||||
// logic).
|
// logic).
|
||||||
it("POST /api/licenses returns 401 without a session", async () => {
|
it("POST /api/licenses returns 401 without a session", async () => {
|
||||||
|
const vendor = await createTestVendor();
|
||||||
mockedAuth.mockResolvedValue(null);
|
mockedAuth.mockResolvedValue(null);
|
||||||
|
|
||||||
const res = await POST(
|
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);
|
expect(res.status).toBe(401);
|
||||||
|
|||||||
Reference in New Issue
Block a user