From 6cdcb4fa9fdd5d2805145a37d6ddf388af5773dc Mon Sep 17 00:00:00 2001 From: DEls Date: Fri, 28 Aug 2026 12:31:23 +0200 Subject: [PATCH] feat(fuel): POST/DELETE /api/vehicles/[id]/attention-dismiss Sets or clears vehicles.attention_dismissed_at via pbAdmin(). 15-char PB id pattern, 404 when PB reports the record is missing, 500 on any other write failure. Test mocks pbAdmin() so no live PB needed. --- .../[id]/attention-dismiss/route.test.ts | 127 ++++++++++++++++++ .../vehicles/[id]/attention-dismiss/route.ts | 82 +++++++++++ 2 files changed, 209 insertions(+) create mode 100644 src/app/api/vehicles/[id]/attention-dismiss/route.test.ts create mode 100644 src/app/api/vehicles/[id]/attention-dismiss/route.ts diff --git a/src/app/api/vehicles/[id]/attention-dismiss/route.test.ts b/src/app/api/vehicles/[id]/attention-dismiss/route.test.ts new file mode 100644 index 0000000..31b2718 --- /dev/null +++ b/src/app/api/vehicles/[id]/attention-dismiss/route.test.ts @@ -0,0 +1,127 @@ +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 { POST, DELETE } from "./route"; + +const ID = "abcdefghijklmno"; // 15 chars, matches PB ID pattern + +function post(id: string) { + return POST( + new Request("http://x", { method: "POST" }), + { params: Promise.resolve({ id }) }, + ); +} + +function del(id: string) { + return DELETE( + new Request("http://x", { method: "DELETE" }), + { params: Promise.resolve({ id }) }, + ); +} + +describe("POST /api/vehicles/[id]/attention-dismiss", () => { + 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 a bad id format", async () => { + const res = await post("not-a-pb-id"); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "bad_id" }); + expect(updateMock).not.toHaveBeenCalled(); + }); + + it("returns 200 and writes the current ISO timestamp on success", async () => { + const fixedNow = "2026-08-28T10:00:00.000Z"; + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date(fixedNow)); + updateMock.mockResolvedValueOnce({ id: ID, attention_dismissed_at: fixedNow }); + const res = await post(ID); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.attentionDismissedAt).toBe(fixedNow); + expect(updateMock).toHaveBeenCalledWith(ID, { + attention_dismissed_at: fixedNow, + }); + } finally { + vi.useRealTimers(); + } + }); + + it("returns 404 when PB reports the vehicle does not exist", async () => { + updateMock.mockRejectedValueOnce(new Error("404 Not Found")); + const res = await post(ID); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: "not_found" }); + }); + + it("returns 500 on unexpected update errors", async () => { + updateMock.mockRejectedValueOnce(new Error("write conflict")); + const res = await post(ID); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: "update_failed" }); + }); +}); + +describe("DELETE /api/vehicles/[id]/attention-dismiss", () => { + 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 a bad id format", async () => { + const res = await del("not-a-pb-id"); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "bad_id" }); + expect(updateMock).not.toHaveBeenCalled(); + }); + + it("returns 200 and clears the field on success", async () => { + updateMock.mockResolvedValueOnce({ id: ID, attention_dismissed_at: null }); + const res = await del(ID); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ attentionDismissedAt: null }); + expect(updateMock).toHaveBeenCalledWith(ID, { attention_dismissed_at: null }); + }); + + it("returns 404 when PB reports the vehicle does not exist", async () => { + updateMock.mockRejectedValueOnce(new Error("404 Not Found")); + const res = await del(ID); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: "not_found" }); + }); + + it("returns 500 on unexpected update errors", async () => { + updateMock.mockRejectedValueOnce(new Error("write conflict")); + const res = await del(ID); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: "update_failed" }); + }); +}); \ No newline at end of file diff --git a/src/app/api/vehicles/[id]/attention-dismiss/route.ts b/src/app/api/vehicles/[id]/attention-dismiss/route.ts new file mode 100644 index 0000000..19c5e7f --- /dev/null +++ b/src/app/api/vehicles/[id]/attention-dismiss/route.ts @@ -0,0 +1,82 @@ +import { NextResponse } from "next/server"; +import { pbAdmin } from "@/lib/pb"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const ID_PATTERN = /^[a-z0-9]{15}$/; + +/** + * POST /api/vehicles/[id]/attention-dismiss + * Stamps `attention_dismissed_at = now()` on the vehicle. Hides it + * from the Fuel dashboard's "Attention required" panel until a + * flagged fuel log with date_logged > the stamp arrives + * (auto-undismiss). + * + * Returns: { attentionDismissedAt: ISO } + * Errors: + * 400 — bad PB id + * 404 — vehicle not found + * 500 — any other PB write failure + * + * DELETE /api/vehicles/[id]/attention-dismiss + * Clears `attention_dismissed_at` (sets it to null). Used by the + * "Restore" affordance on the vehicle detail page. + * + * Returns: { attentionDismissedAt: null } + * Errors: same as POST. + */ +export async function POST( + _request: Request, + ctx: { params: Promise<{ id: string }> }, +) { + const { id } = await ctx.params; + if (!ID_PATTERN.test(id)) { + return NextResponse.json({ error: "bad_id" }, { status: 400 }); + } + + const stamp = new Date().toISOString(); + const pb = await pbAdmin(); + try { + await pb.collection("vehicles").update(id, { + attention_dismissed_at: stamp, + }); + return NextResponse.json({ attentionDismissedAt: stamp }); + } catch (err) { + if (err instanceof Error && /404/.test(err.message)) { + return NextResponse.json({ error: "not_found" }, { status: 404 }); + } + console.error( + "[vehicles/attention-dismiss] POST failed", + { id, err: String(err) }, + ); + return NextResponse.json({ error: "update_failed" }, { status: 500 }); + } +} + +export async function DELETE( + _request: Request, + ctx: { params: Promise<{ id: string }> }, +) { + const { id } = await ctx.params; + if (!ID_PATTERN.test(id)) { + return NextResponse.json({ error: "bad_id" }, { status: 400 }); + } + + const pb = await pbAdmin(); + try { + await pb.collection("vehicles").update(id, { + attention_dismissed_at: null, + }); + return NextResponse.json({ attentionDismissedAt: null }); + } catch (err) { + if (err instanceof Error && /404/.test(err.message)) { + return NextResponse.json({ error: "not_found" }, { status: 404 }); + } + console.error( + "[vehicles/attention-dismiss] DELETE failed", + { id, err: String(err) }, + ); + return NextResponse.json({ error: "update_failed" }, { status: 500 }); + } +} \ No newline at end of file