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 new file mode 100644 index 0000000..3b012ad --- /dev/null +++ b/src/app/api/v1/boss-dashboard/rep-activity/route.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const assertBrowserSession = vi.fn(); +const getBossRepActivity = vi.fn(); + +vi.mock("@/lib/server/browser-session", () => ({ + assertBrowserSession: (...args: unknown[]) => assertBrowserSession(...args), +})); + +vi.mock("@/lib/server/boss-dashboard-service", () => ({ + getBossRepActivity: (...args: unknown[]) => getBossRepActivity(...args), +})); + +import { GET } from "./route"; + +// --- Helpers -------------------------------------------------------------- + +function authedRequest(url: string) { + return new NextRequest(url, { + headers: { authorization: "Bearer test-token" }, + }); +} + +function unauthedRequest(url: string) { + return new NextRequest(url); +} + +// --- Tests ---------------------------------------------------------------- + +describe("GET /api/v1/boss-dashboard/rep-activity", () => { + beforeEach(() => { + vi.clearAllMocks(); + assertBrowserSession.mockResolvedValue(undefined); + }); + + it("401s when the authorization header is missing", async () => { + // assertBrowserSession is what enforces auth; have it throw to mirror the + // real production behaviour when a missing header is forwarded. + assertBrowserSession.mockImplementation(() => { + throw Object.assign(new Error("Not authenticated."), { status: 401 }); + }); + + const res = await GET( + unauthedRequest( + "http://localhost/api/v1/boss-dashboard/rep-activity?rep=u-1", + ), + ); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toMatch(/auth/i); + expect(getBossRepActivity).not.toHaveBeenCalled(); + }); + + it("400s when the rep query param is missing", async () => { + const res = await GET( + authedRequest("http://localhost/api/v1/boss-dashboard/rep-activity"), + ); + 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( + "http://localhost/api/v1/boss-dashboard/rep-activity?rep=u-1&window=garbage", + ), + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/Invalid window/); + expect(getBossRepActivity).not.toHaveBeenCalled(); + }); + + it("400s when the kind query param is not in the allow-list", async () => { + const res = await GET( + authedRequest( + "http://localhost/api/v1/boss-dashboard/rep-activity?rep=u-1&kind=garbage", + ), + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/Invalid kind/); + expect(getBossRepActivity).not.toHaveBeenCalled(); + }); + + it("returns 200 with the service payload (kind=quotes) on success", async () => { + getBossRepActivity.mockResolvedValueOnce({ + items: [ + { + id: "q-1", + number: "Q-2026-08-014", + customer: "North Pit", + status: "emailed", + ageMs: 1_000, + }, + ], + hasMore: false, + }); + + const res = await GET( + authedRequest( + "http://localhost/api/v1/boss-dashboard/rep-activity?rep=u-1&kind=quotes", + ), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.items).toHaveLength(1); + expect(body.items[0]).toMatchObject({ + id: "q-1", + number: "Q-2026-08-014", + customer: "North Pit", + status: "emailed", + }); + expect(body.hasMore).toBe(false); + + // Defaults: window="today", page=1. + expect(getBossRepActivity).toHaveBeenCalledWith({ + rep: "u-1", + window: "today", + kind: "quotes", + page: 1, + }); + }); + + it("returns 200 with the service payload (kind=orders) on success", async () => { + getBossRepActivity.mockResolvedValueOnce({ + items: [ + { + id: "o-1", + number: "ORD-2026-08-009", + customer: "Anglo", + status: "pending", + ageMs: 5_000, + }, + { + id: "o-2", + number: "ORD-2026-08-008", + customer: "Sasol", + status: "delivered", + ageMs: 10_000, + }, + ], + hasMore: true, + }); + + const res = await GET( + authedRequest( + "http://localhost/api/v1/boss-dashboard/rep-activity?rep=u-2&kind=orders", + ), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.items).toHaveLength(2); + expect(body.hasMore).toBe(true); + expect(getBossRepActivity).toHaveBeenCalledWith({ + rep: "u-2", + window: "today", + kind: "orders", + page: 1, + }); + }); + + it("forwards explicit window and page params to the service", async () => { + getBossRepActivity.mockResolvedValueOnce({ items: [], hasMore: false }); + + const res = await GET( + authedRequest( + "http://localhost/api/v1/boss-dashboard/rep-activity?rep=u-3&window=week&kind=quotes&page=4", + ), + ); + expect(res.status).toBe(200); + expect(getBossRepActivity).toHaveBeenCalledWith({ + rep: "u-3", + window: "week", + kind: "quotes", + page: 4, + }); + }); + + it("clamps page to the [1, 1000] range", async () => { + getBossRepActivity.mockResolvedValueOnce({ items: [], hasMore: false }); + + // page=0 → clamped up to 1. + await GET( + authedRequest( + "http://localhost/api/v1/boss-dashboard/rep-activity?rep=u-4&page=0", + ), + ); + expect(getBossRepActivity).toHaveBeenLastCalledWith( + expect.objectContaining({ page: 1 }), + ); + + // page=99999 → clamped down to 1000. + await GET( + authedRequest( + "http://localhost/api/v1/boss-dashboard/rep-activity?rep=u-4&page=99999", + ), + ); + expect(getBossRepActivity).toHaveBeenLastCalledWith( + expect.objectContaining({ page: 1000 }), + ); + + // Garbage page → falls back to 1. + await GET( + authedRequest( + "http://localhost/api/v1/boss-dashboard/rep-activity?rep=u-4&page=abc", + ), + ); + expect(getBossRepActivity).toHaveBeenLastCalledWith( + expect.objectContaining({ page: 1 }), + ); + }); +}); diff --git a/src/app/api/v1/boss-dashboard/route.test.ts b/src/app/api/v1/boss-dashboard/route.test.ts new file mode 100644 index 0000000..f9bfffa --- /dev/null +++ b/src/app/api/v1/boss-dashboard/route.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const getBossDashboardData = vi.fn(); + +vi.mock("@/lib/server/boss-dashboard-service", () => ({ + getBossDashboardData: (...args: unknown[]) => getBossDashboardData(...args), +})); + +import { GET } from "./route"; + +// --- Helpers -------------------------------------------------------------- + +const ORIGINAL_PB_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL; + +function authedRequest(url: string) { + return new NextRequest(url, { + headers: { authorization: "Bearer test-token" }, + }); +} + +function unauthedRequest(url: string) { + return new NextRequest(url); +} + +/** + * 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. + */ +function mockAuthRefreshOk(user: { id: string; name: string } = { id: "u1", name: "Boss" }) { + return vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response(JSON.stringify({ record: user }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); +} + +function mockAuthRefreshFail(status = 401) { + return vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response("nope", { status })); +} + +// --- Tests ---------------------------------------------------------------- + +describe("GET /api/v1/boss-dashboard", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.NEXT_PUBLIC_POCKETBASE_URL = "http://127.0.0.1:8090"; + }); + + afterEach(() => { + if (ORIGINAL_PB_URL === undefined) delete process.env.NEXT_PUBLIC_POCKETBASE_URL; + else process.env.NEXT_PUBLIC_POCKETBASE_URL = ORIGINAL_PB_URL; + vi.restoreAllMocks(); + }); + + it("401s when the authorization header is missing", async () => { + // No fetch mock — the route should bail before hitting PB. + const res = await GET(unauthedRequest("http://localhost/api/v1/boss-dashboard")); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toMatch(/auth/i); + expect(getBossDashboardData).not.toHaveBeenCalled(); + }); + + it("401s when PB's auth-refresh rejects the token", async () => { + mockAuthRefreshFail(401); + + const res = await GET(authedRequest("http://localhost/api/v1/boss-dashboard")); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toMatch(/invalid|expired|session/i); + expect(getBossDashboardData).not.toHaveBeenCalled(); + }); + + it("400s when the window query param is not in the allow-list", async () => { + mockAuthRefreshOk(); + + const res = await GET( + authedRequest("http://localhost/api/v1/boss-dashboard?window=garbage"), + ); + 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({ + window: "today", + rangeStart: "2026-08-28T00:00:00.000Z", + rangeEnd: "2026-08-29T00:00:00.000Z", + orderTotals: { total: 7, delivered: 3, invoiced: 1 }, + reps: [ + { + id: "u1", + name: "Alice", + isUnknown: false, + quotesLoaded: 4, + quotesDoneFor: 2, + quotesDoneBy: 1, + ordersPlaced: 3, + }, + ], + drivers: [], + }); + + const res = await GET( + authedRequest("http://localhost/api/v1/boss-dashboard?window=today"), + ); + expect(res.status).toBe(200); + const body = await res.json(); + + // Service payload passed through. + expect(body.window).toBe("today"); + expect(body.rangeStart).toBe("2026-08-28T00:00:00.000Z"); + expect(body.rangeEnd).toBe("2026-08-29T00:00:00.000Z"); + expect(body.orderTotals).toEqual({ total: 7, delivered: 3, invoiced: 1 }); + expect(body.reps).toHaveLength(1); + expect(body.reps[0].name).toBe("Alice"); + + // viewerName merged from the auth-refresh response. + expect(body.viewerName).toBe("Dave"); + + // Service was called with the validated window string. + expect(getBossDashboardData).toHaveBeenCalledWith({ window: "today" }); + }); + + it("defaults window to 'today' when no window param is given", async () => { + mockAuthRefreshOk(); + 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); + expect(getBossDashboardData).toHaveBeenCalledWith({ window: "today" }); + }); + + it("forwards the 'week' window to the service", async () => { + mockAuthRefreshOk(); + getBossDashboardData.mockResolvedValueOnce({ + window: "week", + rangeStart: "2026-08-24T00:00:00.000Z", + rangeEnd: "2026-08-31T00:00:00.000Z", + orderTotals: { total: 0, delivered: 0, invoiced: 0 }, + reps: [], + drivers: [], + }); + + const res = await GET( + authedRequest("http://localhost/api/v1/boss-dashboard?window=week"), + ); + expect(res.status).toBe(200); + expect(getBossDashboardData).toHaveBeenCalledWith({ window: "week" }); + }); + + it("forwards the 'month' window to the service", async () => { + mockAuthRefreshOk(); + getBossDashboardData.mockResolvedValueOnce({ + window: "month", + rangeStart: "2026-08-01T00:00:00.000Z", + rangeEnd: "2026-09-01T00:00:00.000Z", + orderTotals: { total: 0, delivered: 0, invoiced: 0 }, + reps: [], + drivers: [], + }); + + const res = await GET( + authedRequest("http://localhost/api/v1/boss-dashboard?window=month"), + ); + expect(res.status).toBe(200); + expect(getBossDashboardData).toHaveBeenCalledWith({ window: "month" }); + }); +});