From 15206eaf49e90011a21b149afcab20934b2d583a Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 28 Aug 2026 00:14:06 +0200 Subject: [PATCH] test(boss-dashboard): add 500/null-fallback/edge-case coverage + tighten mock hygiene --- .../boss-dashboard/rep-activity/route.test.ts | 40 +++++++++++-- src/app/api/v1/boss-dashboard/route.test.ts | 60 ++++++++++++++++++- 2 files changed, 93 insertions(+), 7 deletions(-) diff --git a/src/app/api/v1/boss-dashboard/rep-activity/route.test.ts b/src/app/api/v1/boss-dashboard/rep-activity/route.test.ts index 3b012ad..7c766f5 100644 --- a/src/app/api/v1/boss-dashboard/rep-activity/route.test.ts +++ b/src/app/api/v1/boss-dashboard/rep-activity/route.test.ts @@ -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"); + }); }); diff --git a/src/app/api/v1/boss-dashboard/route.test.ts b/src/app/api/v1/boss-dashboard/route.test.ts index f9bfffa..bb5bbfa 100644 --- a/src/app/api/v1/boss-dashboard/route.test.ts +++ b/src/app/api/v1/boss-dashboard/route.test.ts @@ -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({