Merge branch 'feat/asset-item-model-id'

Retrofit AssetItem.model (string) -> modelId (FK to AssetModel) —
closes out Lane B (AssetCategory -> AssetCategoryAttributeDef +
AssetModel -> dynamic specs validation -> AssetItem.modelId).
Verified green by test-runner (225/225 tests, tsc/lint/build clean)
before merge.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
toanhoang
2026-08-02 21:11:13 -04:00
co-authored by Claude Sonnet 5
5 changed files with 169 additions and 46 deletions
@@ -0,0 +1,13 @@
/*
Warnings:
- You are about to drop the column `model` on the `asset_items` table. All the data in the column will be lost.
- Added the required column `model_id` to the `asset_items` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "asset_items" DROP COLUMN "model",
ADD COLUMN "model_id" UUID NOT NULL;
-- AddForeignKey
ALTER TABLE "asset_items" ADD CONSTRAINT "asset_items_model_id_fkey" FOREIGN KEY ("model_id") REFERENCES "asset_models"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+14 -1
View File
@@ -97,10 +97,20 @@ enum AssetItemStatus {
// a formal PO (e.g. historical stock predating the system). Set at // a formal PO (e.g. historical stock predating the system). Set at
// intake/creation time only — no update endpoint for this field yet, same // intake/creation time only — no update endpoint for this field yet, same
// precedent as License.vendorId being set-at-creation with no PATCH. // precedent as License.vendorId being set-at-creation with no PATCH.
// `modelId` was a free-text `model` String until the "Cập nhật 2026-08-02
// (2)" retrofit (docs/asset-license-management.md's "Ràng buộc dữ liệu"
// bullet) — same precedent as Request.employeeId/License.vendorId (string
// -> FK once the referenced entity existed). No production `AssetItem` data
// existed at the time of this change, so this was a plain column-type
// migration, not a backfill. Required (not nullable) — every AssetItem must
// belong to a real AssetModel. AssetItem deliberately does NOT carry its own
// `categoryId` — category is derived via `modelId -> AssetModel.categoryId`
// to avoid two sources of truth that could drift apart.
model AssetItem { model AssetItem {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
serialNumber String @unique @map("serial_number") serialNumber String @unique @map("serial_number")
model String modelId String @map("model_id") @db.Uuid
model AssetModel @relation(fields: [modelId], references: [id])
purchaseDate DateTime @map("purchase_date") @db.Date purchaseDate DateTime @map("purchase_date") @db.Date
warrantyEndDate DateTime? @map("warranty_end_date") @db.Date warrantyEndDate DateTime? @map("warranty_end_date") @db.Date
status AssetItemStatus @default(in_stock) status AssetItemStatus @default(in_stock)
@@ -327,6 +337,9 @@ model AssetModel {
name String name String
specs Json @default("{}") specs Json @default("{}")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
// Back-relation for the AssetItem.model -> AssetItem.modelId retrofit —
// see "Cập nhật 2026-08-02 (2)" in docs/asset-license-management.md.
assetItems AssetItem[]
@@map("asset_models") @@map("asset_models")
} }
+2 -2
View File
@@ -22,11 +22,11 @@ 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 { serialNumber, model, purchaseDate, warrantyEndDate, purchaseOrderId } = const { serialNumber, modelId, purchaseDate, warrantyEndDate, purchaseOrderId } =
body as Record<string, unknown>; body as Record<string, unknown>;
const validation = validateCreateAssetItemInput({ const validation = validateCreateAssetItemInput({
serialNumber, serialNumber,
model, modelId,
purchaseDate, purchaseDate,
warrantyEndDate, warrantyEndDate,
purchaseOrderId, purchaseOrderId,
+34 -18
View File
@@ -7,7 +7,7 @@ export type AssetItemStatus = "in_stock" | "assigned" | "repair" | "retired";
export interface AssetItemRecord { export interface AssetItemRecord {
id: string; id: string;
serial_number: string; serial_number: string;
model: string; model_id: string;
purchase_date: string; purchase_date: string;
warranty_end_date: string | null; warranty_end_date: string | null;
status: AssetItemStatus; status: AssetItemStatus;
@@ -17,7 +17,7 @@ export interface AssetItemRecord {
export interface CreateAssetItemInput { export interface CreateAssetItemInput {
serialNumber: string; serialNumber: string;
model: string; modelId: string;
purchaseDate: Date; purchaseDate: Date;
warrantyEndDate?: Date; warrantyEndDate?: Date;
purchaseOrderId?: string | null; purchaseOrderId?: string | null;
@@ -47,7 +47,15 @@ function dateString(message: string) {
const createAssetItemSchema = z.object({ const createAssetItemSchema = z.object({
serialNumber: nonEmptyString("serialNumber is required and must be a non-empty string"), serialNumber: nonEmptyString("serialNumber is required and must be a non-empty string"),
model: nonEmptyString("model is required and must be a non-empty string"), // Required FK to AssetModel — replaced the free-text `model` String (see
// schema.prisma / docs/asset-license-management.md's "Cập nhật 2026-08-02
// (2)" section). Only a UUID *format* check here, whether it references a
// real AssetModel is left to Prisma's own FK constraint at insert time
// (see createAssetItem()'s P2003 handling below). Mirrors
// src/lib/licenses.ts's vendorId handling.
modelId: z
.string({ required_error: "modelId is required and must be a valid UUID string" })
.uuid("modelId is required and must be a valid UUID string"),
purchaseDate: dateString("purchaseDate is required and must be a valid date string"), purchaseDate: dateString("purchaseDate is required and must be a valid date string"),
warrantyEndDate: dateString("warrantyEndDate must be a valid date string").optional(), warrantyEndDate: dateString("warrantyEndDate must be a valid date string").optional(),
// FK to PurchaseOrder — nullable/optional, supply-side traceability (not // FK to PurchaseOrder — nullable/optional, supply-side traceability (not
@@ -64,17 +72,17 @@ const createAssetItemSchema = z.object({
}); });
/** /**
* Validates raw (untyped) asset-item-creation input. serialNumber/model * Validates raw (untyped) asset-item-creation input. serialNumber must be a
* must be non-empty strings, purchaseDate must be a valid date string, * non-empty string, modelId must be a valid UUID string, purchaseDate must
* warrantyEndDate is optional but must be a valid date string if present. * be a valid date string, warrantyEndDate is optional but must be a valid
* purchaseOrderId is optional/nullable but must be a valid UUID if present. * date string if present. purchaseOrderId is optional/nullable but must be
* status is not accepted here — new assets always start "in_stock" (same * a valid UUID if present. status is not accepted here — new assets always
* pattern as Request always starting "Draft"), later states are set via * start "in_stock" (same pattern as Request always starting "Draft"), later
* other operations, not at creation. * states are set via other operations, not at creation.
*/ */
export function validateCreateAssetItemInput(input: { export function validateCreateAssetItemInput(input: {
serialNumber?: unknown; serialNumber?: unknown;
model?: unknown; modelId?: unknown;
purchaseDate?: unknown; purchaseDate?: unknown;
warrantyEndDate?: unknown; warrantyEndDate?: unknown;
purchaseOrderId?: unknown; purchaseOrderId?: unknown;
@@ -90,7 +98,7 @@ export function validateCreateAssetItemInput(input: {
function toRecord(row: { function toRecord(row: {
id: string; id: string;
serialNumber: string; serialNumber: string;
model: string; modelId: string;
purchaseDate: Date; purchaseDate: Date;
warrantyEndDate: Date | null; warrantyEndDate: Date | null;
status: AssetItemStatus; status: AssetItemStatus;
@@ -100,7 +108,7 @@ function toRecord(row: {
return { return {
id: row.id, id: row.id,
serial_number: row.serialNumber, serial_number: row.serialNumber,
model: row.model, model_id: row.modelId,
purchase_date: row.purchaseDate.toISOString(), purchase_date: row.purchaseDate.toISOString(),
warranty_end_date: row.warrantyEndDate ? row.warrantyEndDate.toISOString() : null, warranty_end_date: row.warrantyEndDate ? row.warrantyEndDate.toISOString() : null,
status: row.status, status: row.status,
@@ -119,10 +127,14 @@ export type CreateAssetItemResult =
* caught here and translated into the same `{ok:false,error}` shape as * caught here and translated into the same `{ok:false,error}` shape as
* input validation failures, rather than letting a raw Prisma error leak * input validation failures, rather than letting a raw Prisma error leak
* to the API layer. Mirrors createEmployee()/createRequest()'s P2003 * to the API layer. Mirrors createEmployee()/createRequest()'s P2003
* handling for FK violations. If purchaseOrderId doesn't reference an * handling for FK violations. Two separate FKs can fail here (modelId ->
* existing PurchaseOrder, Prisma raises P2003 (FK constraint violation) — * AssetModel, purchaseOrderId -> PurchaseOrder) — Prisma raises the same
* caught the same way, mirrors src/lib/purchase-orders.ts's * error code (P2003) for either, so the FK constraint name in
* createPurchaseOrder() P2003 handling. * `error.meta.field_name` is used to tell them apart and return a targeted
* message, rather than a raw Prisma error leaking to the API layer. Mirrors
* src/lib/licenses.ts's createLicense() / src/lib/purchase-orders.ts's
* createPurchaseOrder() pattern for disambiguating between two FKs on the
* same model.
*/ */
export async function createAssetItem( export async function createAssetItem(
input: CreateAssetItemInput input: CreateAssetItemInput
@@ -131,7 +143,7 @@ export async function createAssetItem(
const created = await prisma.assetItem.create({ const created = await prisma.assetItem.create({
data: { data: {
serialNumber: input.serialNumber, serialNumber: input.serialNumber,
model: input.model, modelId: input.modelId,
purchaseDate: input.purchaseDate, purchaseDate: input.purchaseDate,
warrantyEndDate: input.warrantyEndDate ?? null, warrantyEndDate: input.warrantyEndDate ?? null,
purchaseOrderId: input.purchaseOrderId ?? null, purchaseOrderId: input.purchaseOrderId ?? null,
@@ -143,8 +155,12 @@ export async function createAssetItem(
return { ok: false, error: "serialNumber already exists" }; return { ok: false, error: "serialNumber already exists" };
} }
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
const fieldName = typeof error.meta?.field_name === "string" ? error.meta.field_name : "";
if (fieldName.includes("purchase_order")) {
return { ok: false, error: "purchaseOrderId does not reference an existing purchase order" }; return { ok: false, error: "purchaseOrderId does not reference an existing purchase order" };
} }
return { ok: false, error: "modelId does not reference an existing asset model" };
}
throw error; throw error;
} }
} }
+105 -24
View File
@@ -50,16 +50,52 @@ function createTestPurchaseOrder(overrides: { vendorId?: string } = {}) {
}); });
} }
// AssetItem.modelId is a real FK to AssetModel (see schema.prisma /
// src/lib/assets.ts), and AssetModel.categoryId is a real FK to
// AssetCategory — tests that need a valid modelId create the full chain
// first and use the model's id. Mirrors tests/asset-models.test.ts's
// createTestAssetCategory() helper. Category is always high_value here —
// AssetModel only ever references high_value categories per
// src/lib/asset-models.ts's business rule.
function createTestAssetCategory(overrides: { name?: string } = {}) {
return prisma.assetCategory.create({
data: {
name: overrides.name ?? "Laptop",
valueGroup: "high_value",
},
});
}
function createTestAssetModel(overrides: { categoryId?: string } = {}) {
return prisma.assetModel.create({
data: {
categoryId: overrides.categoryId as string,
manufacturer: "Dell",
name: "Latitude 5440",
},
});
}
async function createTestModelId(): Promise<string> {
const category = await createTestAssetCategory();
const model = await createTestAssetModel({ categoryId: category.id });
return model.id;
}
beforeEach(async () => { beforeEach(async () => {
// Each test starts from empty tables so listing/order assertions are // Each test starts from empty tables so listing/order assertions are
// deterministic. asset_items must be cleared before purchase_orders/ // deterministic. asset_items must be cleared before purchase_orders/
// vendors since asset_items has FKs (ON DELETE SET NULL to // vendors/asset_models/asset_categories since asset_items has FKs to all
// purchase_orders) — deleteMany order avoids relying on that cascade // of them — deleteMany order avoids relying on cascade behavior in tests.
// behavior in tests. purchase_orders must be cleared before vendors // purchase_orders must be cleared before vendors (ON DELETE RESTRICT to
// (ON DELETE RESTRICT to vendors, mirrors tests/purchase-orders.test.ts). // vendors, mirrors tests/purchase-orders.test.ts). asset_models must be
// cleared before asset_categories (ON DELETE RESTRICT, mirrors
// tests/asset-models.test.ts's cleanup order).
await prisma.assetItem.deleteMany(); await prisma.assetItem.deleteMany();
await prisma.purchaseOrder.deleteMany(); await prisma.purchaseOrder.deleteMany();
await prisma.vendor.deleteMany(); await prisma.vendor.deleteMany();
await prisma.assetModel.deleteMany();
await prisma.assetCategory.deleteMany();
// Default to a logged-in session — the tests below exercise business // Default to a logged-in session — the tests below exercise business
// logic, not auth gating (that's covered by the "auth gating" describe // logic, not auth gating (that's covered by the "auth gating" describe
// block further down). // block further down).
@@ -68,8 +104,9 @@ beforeEach(async () => {
describe("POST /api/assets", () => { describe("POST /api/assets", () => {
it("creates an asset item in in_stock status on valid input, with no warrantyEndDate", async () => { it("creates an asset item in in_stock status on valid input, with no warrantyEndDate", async () => {
const modelId = await createTestModelId();
const res = await POST( const res = await POST(
postRequest({ serialNumber: "SN-001", model: "Dell Latitude 5440", purchaseDate: "2026-01-15" }) postRequest({ serialNumber: "SN-001", modelId, purchaseDate: "2026-01-15" })
); );
expect(res.status).toBe(201); expect(res.status).toBe(201);
@@ -77,7 +114,7 @@ describe("POST /api/assets", () => {
expect(body).toMatchObject({ expect(body).toMatchObject({
serial_number: "SN-001", serial_number: "SN-001",
model: "Dell Latitude 5440", model_id: modelId,
status: "in_stock", status: "in_stock",
warranty_end_date: null, warranty_end_date: null,
}); });
@@ -88,10 +125,11 @@ describe("POST /api/assets", () => {
}); });
it("creates an asset item with a warrantyEndDate", async () => { it("creates an asset item with a warrantyEndDate", async () => {
const modelId = await createTestModelId();
const res = await POST( const res = await POST(
postRequest({ postRequest({
serialNumber: "SN-002", serialNumber: "SN-002",
model: "Dell Latitude 5440", modelId,
purchaseDate: "2026-01-15", purchaseDate: "2026-01-15",
warrantyEndDate: "2029-01-15", warrantyEndDate: "2029-01-15",
}) })
@@ -103,24 +141,26 @@ describe("POST /api/assets", () => {
}); });
it("rejects an empty serialNumber", async () => { it("rejects an empty serialNumber", async () => {
const res = await POST(postRequest({ serialNumber: "", model: "Dell", purchaseDate: "2026-01-15" })); const modelId = await createTestModelId();
const res = await POST(postRequest({ serialNumber: "", modelId, purchaseDate: "2026-01-15" }));
expect(res.status).toBe(400); expect(res.status).toBe(400);
const body = await res.json(); const body = await res.json();
expect(body.error).toMatch(/serialNumber/i); expect(body.error).toMatch(/serialNumber/i);
}); });
it("rejects an empty model", async () => { it("rejects a missing modelId", async () => {
const res = await POST(postRequest({ serialNumber: "SN-003", model: "", purchaseDate: "2026-01-15" })); const res = await POST(postRequest({ serialNumber: "SN-003", purchaseDate: "2026-01-15" }));
expect(res.status).toBe(400); expect(res.status).toBe(400);
const body = await res.json(); const body = await res.json();
expect(body.error).toMatch(/model/i); expect(body.error).toMatch(/modelId/i);
}); });
it("rejects an invalid purchaseDate", async () => { it("rejects an invalid purchaseDate", async () => {
const modelId = await createTestModelId();
const res = await POST( const res = await POST(
postRequest({ serialNumber: "SN-004", model: "Dell", purchaseDate: "not-a-date" }) postRequest({ serialNumber: "SN-004", modelId, purchaseDate: "not-a-date" })
); );
expect(res.status).toBe(400); expect(res.status).toBe(400);
@@ -129,10 +169,11 @@ describe("POST /api/assets", () => {
}); });
it("rejects an invalid warrantyEndDate", async () => { it("rejects an invalid warrantyEndDate", async () => {
const modelId = await createTestModelId();
const res = await POST( const res = await POST(
postRequest({ postRequest({
serialNumber: "SN-005", serialNumber: "SN-005",
model: "Dell", modelId,
purchaseDate: "2026-01-15", purchaseDate: "2026-01-15",
warrantyEndDate: "not-a-date", warrantyEndDate: "not-a-date",
}) })
@@ -144,13 +185,14 @@ describe("POST /api/assets", () => {
}); });
it("creates an asset item with a purchaseOrderId that references a real purchase order", async () => { it("creates an asset item with a purchaseOrderId that references a real purchase order", async () => {
const modelId = await createTestModelId();
const vendor = await createTestVendor(); const vendor = await createTestVendor();
const purchaseOrder = await createTestPurchaseOrder({ vendorId: vendor.id }); const purchaseOrder = await createTestPurchaseOrder({ vendorId: vendor.id });
const res = await POST( const res = await POST(
postRequest({ postRequest({
serialNumber: "SN-007", serialNumber: "SN-007",
model: "Dell Latitude 5440", modelId,
purchaseDate: "2026-01-15", purchaseDate: "2026-01-15",
purchaseOrderId: purchaseOrder.id, purchaseOrderId: purchaseOrder.id,
}) })
@@ -162,8 +204,9 @@ describe("POST /api/assets", () => {
}); });
it("creates an asset item with no purchaseOrderId (stays null)", async () => { it("creates an asset item with no purchaseOrderId (stays null)", async () => {
const modelId = await createTestModelId();
const res = await POST( const res = await POST(
postRequest({ serialNumber: "SN-008", model: "Dell Latitude 5440", purchaseDate: "2026-01-15" }) postRequest({ serialNumber: "SN-008", modelId, purchaseDate: "2026-01-15" })
); );
expect(res.status).toBe(201); expect(res.status).toBe(201);
@@ -172,10 +215,11 @@ describe("POST /api/assets", () => {
}); });
it("rejects a purchaseOrderId that is a well-formed UUID but doesn't reference an existing purchase order", async () => { it("rejects a purchaseOrderId that is a well-formed UUID but doesn't reference an existing purchase order", async () => {
const modelId = await createTestModelId();
const res = await POST( const res = await POST(
postRequest({ postRequest({
serialNumber: "SN-009", serialNumber: "SN-009",
model: "Dell Latitude 5440", modelId,
purchaseDate: "2026-01-15", purchaseDate: "2026-01-15",
purchaseOrderId: "00000000-0000-0000-0000-000000000000", purchaseOrderId: "00000000-0000-0000-0000-000000000000",
}) })
@@ -192,10 +236,11 @@ describe("POST /api/assets", () => {
}); });
it("rejects a purchaseOrderId that is not a valid UUID", async () => { it("rejects a purchaseOrderId that is not a valid UUID", async () => {
const modelId = await createTestModelId();
const res = await POST( const res = await POST(
postRequest({ postRequest({
serialNumber: "SN-012", serialNumber: "SN-012",
model: "Dell Latitude 5440", modelId,
purchaseDate: "2026-01-15", purchaseDate: "2026-01-15",
purchaseOrderId: "not-a-uuid", purchaseOrderId: "not-a-uuid",
}) })
@@ -207,8 +252,9 @@ describe("POST /api/assets", () => {
}); });
it("rejects a duplicate serialNumber", async () => { it("rejects a duplicate serialNumber", async () => {
await POST(postRequest({ serialNumber: "SN-006", model: "Dell", purchaseDate: "2026-01-15" })); const modelId = await createTestModelId();
const res = await POST(postRequest({ serialNumber: "SN-006", model: "HP", purchaseDate: "2026-02-01" })); await POST(postRequest({ serialNumber: "SN-006", modelId, purchaseDate: "2026-01-15" }));
const res = await POST(postRequest({ serialNumber: "SN-006", modelId, purchaseDate: "2026-02-01" }));
expect(res.status).toBe(400); expect(res.status).toBe(400);
const body = await res.json(); const body = await res.json();
@@ -218,14 +264,48 @@ describe("POST /api/assets", () => {
const remaining = await prisma.assetItem.findMany(); const remaining = await prisma.assetItem.findMany();
expect(remaining).toHaveLength(1); expect(remaining).toHaveLength(1);
}); });
it("rejects a modelId that is not a valid UUID", async () => {
const res = await POST(
postRequest({ serialNumber: "SN-013", modelId: "not-a-uuid", purchaseDate: "2026-01-15" })
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/modelId/i);
// And it must not have created anything.
const remaining = await prisma.assetItem.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects a modelId that is a well-formed UUID but doesn't reference an existing asset model", async () => {
const res = await POST(
postRequest({
serialNumber: "SN-014",
modelId: "00000000-0000-0000-0000-000000000000",
purchaseDate: "2026-01-15",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/modelId/i);
expect(body.error).toMatch(/does not reference an existing asset model/i);
// And it must not have created anything.
const remaining = await prisma.assetItem.findMany();
expect(remaining).toHaveLength(0);
});
}); });
describe("GET /api/assets", () => { describe("GET /api/assets", () => {
it("lists created asset items, newest first", async () => { it("lists created asset items, newest first", async () => {
await POST(postRequest({ serialNumber: "SN-010", model: "Dell", purchaseDate: "2026-01-15" })); const modelId = await createTestModelId();
await POST(postRequest({ serialNumber: "SN-010", modelId, purchaseDate: "2026-01-15" }));
// 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(postRequest({ serialNumber: "SN-011", model: "HP", purchaseDate: "2026-02-01" })); await POST(postRequest({ serialNumber: "SN-011", modelId, purchaseDate: "2026-02-01" }));
const res = await GET(); const res = await GET();
expect(res.status).toBe(200); expect(res.status).toBe(200);
@@ -235,8 +315,8 @@ describe("GET /api/assets", () => {
expect(body).toHaveLength(2); expect(body).toHaveLength(2);
// newest first // newest first
expect(body[0]).toMatchObject({ serial_number: "SN-011", model: "HP" }); expect(body[0]).toMatchObject({ serial_number: "SN-011", model_id: modelId });
expect(body[1]).toMatchObject({ serial_number: "SN-010", model: "Dell" }); expect(body[1]).toMatchObject({ serial_number: "SN-010", model_id: modelId });
}); });
it("returns an empty list when there are no asset items", async () => { it("returns an empty list when there are no asset items", async () => {
@@ -252,10 +332,11 @@ describe("auth gating on /api/assets", () => {
// 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/assets returns 401 without a session", async () => { it("POST /api/assets returns 401 without a session", async () => {
const modelId = await createTestModelId();
mockedAuth.mockResolvedValue(null); mockedAuth.mockResolvedValue(null);
const res = await POST( const res = await POST(
postRequest({ serialNumber: "SN-020", model: "Dell", purchaseDate: "2026-01-15" }) postRequest({ serialNumber: "SN-020", modelId, purchaseDate: "2026-01-15" })
); );
expect(res.status).toBe(401); expect(res.status).toBe(401);