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:
parent
ffd840eac1
commit
6cdcb4fa9f
2 changed files with 209 additions and 0 deletions
127
src/app/api/vehicles/[id]/attention-dismiss/route.test.ts
Normal file
127
src/app/api/vehicles/[id]/attention-dismiss/route.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
82
src/app/api/vehicles/[id]/attention-dismiss/route.ts
Normal file
82
src/app/api/vehicles/[id]/attention-dismiss/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue