test(boss-dashboard): add 500/null-fallback/edge-case coverage + tighten mock hygiene

This commit is contained in:
Dave 2026-08-28 00:14:06 +02:00
parent e9ce5f4fc4
commit 15206eaf49
2 changed files with 93 additions and 7 deletions

View file

@ -30,7 +30,7 @@ function unauthedRequest(url: string) {
describe("GET /api/v1/boss-dashboard/rep-activity", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetAllMocks();
assertBrowserSession.mockResolvedValue(undefined);
});
@ -62,6 +62,20 @@ describe("GET /api/v1/boss-dashboard/rep-activity", () => {
expect(getBossRepActivity).not.toHaveBeenCalled();
});
it("400s when rep is an empty string (not just absent)", async () => {
// `?rep=` becomes "" — not nullish, so the route's `get(rep)` truthy guard
// would let it through. Lock in that the route explicitly rejects it.
const res = await GET(
authedRequest(
"http://localhost/api/v1/boss-dashboard/rep-activity?rep=",
),
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/Missing rep/);
expect(getBossRepActivity).not.toHaveBeenCalled();
});
it("400s when the window query param is not in the allow-list", async () => {
const res = await GET(
authedRequest(
@ -181,36 +195,52 @@ describe("GET /api/v1/boss-dashboard/rep-activity", () => {
});
it("clamps page to the [1, 1000] range", async () => {
getBossRepActivity.mockResolvedValueOnce({ items: [], hasMore: false });
// Default (not queued) — three GET calls in this test all return empty.
getBossRepActivity.mockResolvedValue({ items: [], hasMore: false });
// page=0 → clamped up to 1.
await GET(
const res1 = await GET(
authedRequest(
"http://localhost/api/v1/boss-dashboard/rep-activity?rep=u-4&page=0",
),
);
expect(res1.status).toBe(200);
expect(getBossRepActivity).toHaveBeenLastCalledWith(
expect.objectContaining({ page: 1 }),
);
// page=99999 → clamped down to 1000.
await GET(
const res2 = await GET(
authedRequest(
"http://localhost/api/v1/boss-dashboard/rep-activity?rep=u-4&page=99999",
),
);
expect(res2.status).toBe(200);
expect(getBossRepActivity).toHaveBeenLastCalledWith(
expect.objectContaining({ page: 1000 }),
);
// Garbage page → falls back to 1.
await GET(
const res3 = await GET(
authedRequest(
"http://localhost/api/v1/boss-dashboard/rep-activity?rep=u-4&page=abc",
),
);
expect(res3.status).toBe(200);
expect(getBossRepActivity).toHaveBeenLastCalledWith(
expect.objectContaining({ page: 1 }),
);
});
it("500s and surfaces the message when the service throws", async () => {
getBossRepActivity.mockRejectedValueOnce(new Error("PB down"));
const res = await GET(
authedRequest(
"http://localhost/api/v1/boss-dashboard/rep-activity?rep=rep-1",
),
);
expect(res.status).toBe(500);
expect((await res.json()).error).toBe("PB down");
});
});

View file

@ -27,6 +27,9 @@ function unauthedRequest(url: string) {
* The dashboard route does an inline `fetch` to PB's auth-refresh endpoint
* (NOT the shared `assertBrowserSession` helper), so we mock globalThis.fetch
* here rather than mocking the helper module.
*
* Both helpers return the spy so callers can either queue one-off responses
* on top (`mockResolvedValueOnce`) or rely on the default fallback (500).
*/
function mockAuthRefreshOk(user: { id: string; name: string } = { id: "u1", name: "Boss" }) {
return vi
@ -36,20 +39,24 @@ function mockAuthRefreshOk(user: { id: string; name: string } = { id: "u1", name
status: 200,
headers: { "content-type": "application/json" },
}),
)
.mockResolvedValue(
new Response("unexpected fetch", { status: 500 }),
);
}
function mockAuthRefreshFail(status = 401) {
return vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(new Response("nope", { status }));
.mockResolvedValueOnce(new Response("nope", { status }))
.mockResolvedValue(new Response("unexpected fetch", { status: 500 }));
}
// --- Tests ----------------------------------------------------------------
describe("GET /api/v1/boss-dashboard", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetAllMocks();
process.env.NEXT_PUBLIC_POCKETBASE_URL = "http://127.0.0.1:8090";
});
@ -90,6 +97,20 @@ describe("GET /api/v1/boss-dashboard", () => {
expect(getBossDashboardData).not.toHaveBeenCalled();
});
it("400s when window is an empty string (not just absent)", async () => {
// ?? only catches nullish; `?? "today"` does NOT apply to "". Locking this in
// prevents a regression where empty strings silently become "today".
mockAuthRefreshOk();
const res = await GET(
authedRequest("http://localhost/api/v1/boss-dashboard?window="),
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/Invalid window/);
expect(getBossDashboardData).not.toHaveBeenCalled();
});
it("returns 200 with the merged dashboard payload + viewerName on success", async () => {
mockAuthRefreshOk({ id: "u-boss", name: "Dave" });
getBossDashboardData.mockResolvedValueOnce({
@ -148,6 +169,41 @@ describe("GET /api/v1/boss-dashboard", () => {
expect(getBossDashboardData).toHaveBeenCalledWith({ window: "today" });
});
it("returns viewerName as null when the PB record has no name", async () => {
// The route does `user?.record?.name ?? null`. A record without a `name`
// (or an empty record) should fall through to null.
vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
new Response(JSON.stringify({ record: { id: "u1" } }), {
status: 200,
headers: { "content-type": "application/json" },
}),
)
.mockResolvedValue(new Response("unexpected fetch", { status: 500 }));
getBossDashboardData.mockResolvedValueOnce({
window: "today",
rangeStart: "2026-08-28T00:00:00.000Z",
rangeEnd: "2026-08-29T00:00:00.000Z",
orderTotals: { total: 0, delivered: 0, invoiced: 0 },
reps: [],
drivers: [],
});
const res = await GET(authedRequest("http://localhost/api/v1/boss-dashboard"));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.viewerName).toBeNull();
});
it("500s and surfaces the message when the service throws", async () => {
mockAuthRefreshOk();
getBossDashboardData.mockRejectedValueOnce(new Error("PB down"));
const res = await GET(authedRequest("http://localhost/api/v1/boss-dashboard"));
expect(res.status).toBe(500);
expect((await res.json()).error).toBe("PB down");
});
it("forwards the 'week' window to the service", async () => {
mockAuthRefreshOk();
getBossDashboardData.mockResolvedValueOnce({