diff --git a/docs/api-integration.md b/docs/api-integration.md index 99e160b..604691a 100644 --- a/docs/api-integration.md +++ b/docs/api-integration.md @@ -139,10 +139,15 @@ type Webhook = { id: string; url: string; events: string[]; createdAt: number }; | Method & path | Type | Request body | Response shape | Source | | --- | --- | --- | --- | --- | -| `GET /api/v1/events?limit={limit}` | Read | — | `{ items: AppEvent[] }` | `events/page.tsx` | +| `GET /api/v1/events?limit={limit}` | Read | — | `{ items: AppEvent[] } \| { events: AppEvent[] }` | `events/page.tsx` | ```ts -type AppEvent = { id: string; ts: number; type: string; payload: Record }; +type AppEvent = { + id: string; + ts: number | string | null; + type: string; + payload: Record; +}; ``` ## Changelog diff --git a/src/app/events/page.test.tsx b/src/app/events/page.test.tsx index ba725e5..2ce3ccb 100644 --- a/src/app/events/page.test.tsx +++ b/src/app/events/page.test.tsx @@ -202,6 +202,96 @@ describe("EventsPage", () => { }); }); + it("throws when an item in the array is not an object", async () => { + const fetchMock = jest.fn(async () => jsonResponse({ items: [null] })); + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + + render(); + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent( + /malformed events payload/i + ); + }); + }); + + it("handles the alternative { events: [...] } response shape", async () => { + const fetchMock = jest.fn(async () => + jsonResponse({ events: FIRST_BATCH.slice(0, 1) }) + ); + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + + render(); + + await waitFor(() => { + expect(screen.getByText("payment.created")).toBeInTheDocument(); + }); + }); + + it("coerces non-numeric/non-string/non-null ts to null", async () => { + const weirdEvents = [ + { + id: "evt-weird", + ts: { some: "object" }, // should be coerced to null + type: "weird.event", + payload: {}, + }, + ]; + + const fetchMock = jest.fn(async () => jsonResponse({ items: weirdEvents })); + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + + render(); + + await waitFor(() => { + expect(screen.getByText("weird.event")).toBeInTheDocument(); + }); + + // When ts is null, safeFormatTimestamp returns \u2014 (em-dash) + // and TimeAgo is not rendered. + expect(screen.getByText("\u2014")).toBeInTheDocument(); + }); + + it("throws when neither items nor events is an array", async () => { + const fetchMock = jest.fn(async () => + jsonResponse({ items: "not-an-array", events: "also-not-an-array" }) + ); + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + + render(); + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent( + /malformed events payload/i + ); + }); + }); + + it("handles missing type field by coercing to empty string", async () => { + const missingTypeEvents = [ + { + id: "evt-no-type", + ts: BASE_TIME.getTime(), + // type is missing + payload: { test: "missing-type" }, + }, + ]; + + const fetchMock = jest.fn(async () => jsonResponse({ items: missingTypeEvents })); + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + + render(); + + await waitFor(() => { + // Verify the payload is rendered, confirming the item was processed + expect(screen.getByText(/"test": "missing-type"/)).toBeInTheDocument(); + }); + + // The type span should be empty but present + const spans = screen.getAllByRole("listitem")[0].querySelectorAll("span"); + expect(spans[0]).toHaveTextContent(""); + }); + it("caps rendered events at 50 and shows 'showing N of M' note when list exceeds cap", async () => { const largeList = Array.from({ length: 75 }, (_, i) => ({ id: `evt-${i}`, diff --git a/src/app/events/page.tsx b/src/app/events/page.tsx index 0d13cc0..7e52ec2 100644 --- a/src/app/events/page.tsx +++ b/src/app/events/page.tsx @@ -27,6 +27,18 @@ function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object"; } +/** + * Parse a loosely-typed EventsResponse into a list of AppEvents. + * + * Supports two response shapes: + * - { items: AppEvent[] } + * - { events: AppEvent[] } + * + * Each event field is validated or coerced to ensure it matches the AppEvent type. + * Specifically, the 'ts' field is coerced to null if it's not a number, string, or null. + * + * @throws {Error} if the payload is not an object or does not contain an array of items/events. + */ function parseEventsResponse(body: EventsResponse): AppEvent[] { const items = Array.isArray(body.items) ? body.items @@ -43,9 +55,15 @@ function parseEventsResponse(body: EventsResponse): AppEvent[] { throw new Error("Malformed events payload"); } + const ts = item.ts; + const validatedTs: AppEvent["ts"] = + typeof ts === "number" || typeof ts === "string" || ts === null + ? ts + : null; + return { id: typeof item.id === "string" ? item.id : String(item.id ?? index), - ts: item.ts as AppEvent["ts"], + ts: validatedTs, type: typeof item.type === "string" ? item.type : String(item.type ?? ""), payload: "payload" in item ? item.payload : undefined, }; diff --git a/src/app/usage/page.tsx b/src/app/usage/page.tsx index 87b27dd..9a74e0b 100644 --- a/src/app/usage/page.tsx +++ b/src/app/usage/page.tsx @@ -50,6 +50,7 @@ export default function UsagePage() { const [agent, setAgent] = useState(""); const [serviceId, setServiceId] = useState(""); const [requests, setRequests] = useState(""); + const [requestsError, setRequestsError] = useState(); const [status, setStatus] = useState({ kind: "idle" }); const [queryAgent, setQueryAgent] = useState(""); const [queryService, setQueryService] = useState(""); @@ -60,10 +61,12 @@ export default function UsagePage() { const onRecord = async (event: FormEvent) => { event.preventDefault(); if (isRecording) return; + setRequestsError(undefined); + const parsed = parsePositiveInt(requests); if (!parsed.ok) { // Surface the validation message through the field error. - setStatus({ kind: "error", message: parsed.message }); + setRequestsError(parsed.message); return; } @@ -142,8 +145,11 @@ export default function UsagePage() { inputMode="numeric" required value={requests} - onChange={(e) => setRequests(e.target.value)} - error={status.kind === "error" ? status.message : undefined} + onChange={(e) => { + setRequests(e.target.value); + setRequestsError(undefined); + }} + error={requestsError} />