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.
This commit is contained in:
DEls 2026-08-28 12:31:23 +02:00
parent ffd840eac1
commit 6cdcb4fa9f
2 changed files with 209 additions and 0 deletions

View file

@ -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" });
});
});

View file

@ -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 });
}
}