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.
This commit is contained in:
DEls 2026-08-28 13:27:22 +02:00
parent b22badb4f0
commit c9789d89f4
2 changed files with 153 additions and 4 deletions

View file

@ -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<string, unknown>) {
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" });
});
});

View file

@ -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(