From c9789d89f4db4e746b9a3fbbab6d3e2eec05a75e Mon Sep 17 00:00:00 2001 From: DEls Date: Fri, 28 Aug 2026 13:27:22 +0200 Subject: [PATCH] feat(vehicles): PATCH /api/vehicles/[id] accepts unit_type Lets the inline-edit flow on /dashboard/plant set the vehicle's unit_type (fuel|plant) so the plant-hours import route's type-mismatch warnings can be resolved by reclassifying the vehicle. Adds a route.test.ts mirroring the attention-dismiss precedent: 11 cases covering happy paths for unit_type='plant'/'fuel', invalid unit_type dropped (with/without other valid fields), existing description+project_code behaviour preserved, JSON parse errors, empty-payload errors, and PB write failures. --- src/app/api/vehicles/[id]/route.test.ts | 146 ++++++++++++++++++++++++ src/app/api/vehicles/[id]/route.ts | 11 +- 2 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 src/app/api/vehicles/[id]/route.test.ts diff --git a/src/app/api/vehicles/[id]/route.test.ts b/src/app/api/vehicles/[id]/route.test.ts new file mode 100644 index 0000000..5d5c2e5 --- /dev/null +++ b/src/app/api/vehicles/[id]/route.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from "vitest"; + +const updateMock = vi.fn(); + +vi.mock("@/lib/pb", () => ({ + pbAdmin: vi.fn(async () => ({ + collection: () => ({ + update: updateMock, + }), + })), +})); + +import { PATCH } from "./route"; + +const ID = "abcdefghijklmno"; // 15 chars, matches PB ID pattern + +function patch(id: string, body: Record) { + return PATCH( + new Request("http://x", { + method: "PATCH", + body: JSON.stringify(body), + }), + { params: Promise.resolve({ id }) }, + ); +} + +describe("PATCH /api/vehicles/[id]", () => { + beforeAll(() => { + vi.stubEnv("POCKETBASE_ADMIN_EMAIL", "test@example.com"); + vi.stubEnv("POCKETBASE_ADMIN_PASSWORD", "test-password"); + }); + + afterAll(() => { + vi.unstubAllEnvs(); + }); + + beforeEach(() => { + updateMock.mockReset(); + }); + + it("returns 400 for non-JSON body", async () => { + const res = await PATCH( + new Request("http://x", { + method: "PATCH", + body: "not-json{", + }), + { params: Promise.resolve({ id: ID }) }, + ); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "Body must be JSON" }); + expect(updateMock).not.toHaveBeenCalled(); + }); + + it("returns 400 when no editable fields are supplied", async () => { + const res = await patch(ID, { foo: "bar" }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "No editable fields supplied" }); + expect(updateMock).not.toHaveBeenCalled(); + }); + + it("accepts unit_type='plant' and forwards it to PB", async () => { + updateMock.mockResolvedValueOnce({ id: ID, unit_type: "plant" }); + const res = await patch(ID, { unit_type: "plant" }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ vehicle: { id: ID, unit_type: "plant" } }); + expect(updateMock).toHaveBeenCalledWith(ID, { unit_type: "plant" }); + }); + + it("accepts unit_type='fuel' and forwards it to PB", async () => { + updateMock.mockResolvedValueOnce({ id: ID, unit_type: "fuel" }); + const res = await patch(ID, { unit_type: "fuel" }); + expect(res.status).toBe(200); + expect(updateMock).toHaveBeenCalledWith(ID, { unit_type: "fuel" }); + }); + + it("drops an invalid unit_type (not 'fuel' or 'plant') and 400s if it's the only field", async () => { + const res = await patch(ID, { unit_type: "truck" }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "No editable fields supplied" }); + expect(updateMock).not.toHaveBeenCalled(); + }); + + it("drops an invalid unit_type but still writes other valid fields", async () => { + updateMock.mockResolvedValueOnce({ id: ID, description: "New" }); + const res = await patch(ID, { description: "New", unit_type: "truck" }); + expect(res.status).toBe(200); + expect(updateMock).toHaveBeenCalledWith(ID, { description: "New" }); + }); + + it("continues to support description + project_code updates", async () => { + updateMock.mockResolvedValueOnce({ id: ID, description: "D", project_code: "P" }); + const res = await patch(ID, { description: "D", project_code: "P" }); + expect(res.status).toBe(200); + expect(updateMock).toHaveBeenCalledWith(ID, { + description: "D", + project_code: "P", + }); + }); + + it("accepts all three editable fields together", async () => { + updateMock.mockResolvedValueOnce({ + id: ID, + description: "D", + project_code: "P", + unit_type: "plant", + }); + const res = await patch(ID, { + description: "D", + project_code: "P", + unit_type: "plant", + }); + expect(res.status).toBe(200); + expect(updateMock).toHaveBeenCalledWith(ID, { + description: "D", + project_code: "P", + unit_type: "plant", + }); + }); + + it("returns 500 when PB update throws", async () => { + updateMock.mockRejectedValueOnce(new Error("write conflict")); + const res = await patch(ID, { unit_type: "plant" }); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: "write conflict" }); + }); + + it("trims description + project_code before forwarding", async () => { + updateMock.mockResolvedValueOnce({ id: ID }); + const res = await patch(ID, { + description: " Padded ", + project_code: " P-001 ", + }); + expect(res.status).toBe(200); + expect(updateMock).toHaveBeenCalledWith(ID, { + description: "Padded", + project_code: "P-001", + }); + }); + + it("drops an empty description (after trimming) but keeps project_code", async () => { + updateMock.mockResolvedValueOnce({ id: ID, project_code: "P" }); + const res = await patch(ID, { description: " ", project_code: "P" }); + expect(res.status).toBe(200); + expect(updateMock).toHaveBeenCalledWith(ID, { project_code: "P" }); + }); +}); diff --git a/src/app/api/vehicles/[id]/route.ts b/src/app/api/vehicles/[id]/route.ts index a1004cf..13a61d2 100644 --- a/src/app/api/vehicles/[id]/route.ts +++ b/src/app/api/vehicles/[id]/route.ts @@ -1,13 +1,13 @@ import { NextResponse } from "next/server"; import { pbAdmin } from "@/lib/pb"; +import type { UnitType } from "@/lib/pb-types"; /** * PATCH /api/vehicles/[id] * - * Updates one or more editable fields on a vehicle record. Currently - * supports `description` and `project_code`; both are optional and only - * present keys are written. Unknown / immutable fields are silently - * dropped (project_code locked for now would be a stricter change). + * Updates editable fields on a vehicle record. Currently supports + * `description`, `project_code`, and `unit_type`. Only present keys + * are written; unknown fields are silently dropped. */ export async function PATCH( request: Request, @@ -29,6 +29,9 @@ export async function PATCH( if (typeof body.project_code === "string") { payload.project_code = body.project_code.trim(); } + if (body.unit_type === "fuel" || body.unit_type === "plant") { + payload.unit_type = body.unit_type as UnitType; + } if (Object.keys(payload).length === 0) { return NextResponse.json(