Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions docs/api-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> };
type AppEvent = {
id: string;
ts: number | string | null;
type: string;
payload: Record<string, unknown>;
};
```

## Changelog
Expand Down
90 changes: 90 additions & 0 deletions src/app/events/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<EventsPage />);

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(<EventsPage />);

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(<EventsPage />);

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(<EventsPage />);

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(<EventsPage />);

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}`,
Expand Down
20 changes: 19 additions & 1 deletion src/app/events/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ function isRecord(value: unknown): value is Record<string, unknown> {
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
Expand All @@ -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,
};
Expand Down
12 changes: 9 additions & 3 deletions src/app/usage/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export default function UsagePage() {
const [agent, setAgent] = useState("");
const [serviceId, setServiceId] = useState("");
const [requests, setRequests] = useState("");
const [requestsError, setRequestsError] = useState<string | undefined>();
const [status, setStatus] = useState<UsageStatus>({ kind: "idle" });
const [queryAgent, setQueryAgent] = useState("");
const [queryService, setQueryService] = useState("");
Expand All @@ -60,10 +61,12 @@ export default function UsagePage() {
const onRecord = async (event: FormEvent<HTMLFormElement>) => {
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;
}

Expand Down Expand Up @@ -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}
/>

<button
Expand Down
10 changes: 5 additions & 5 deletions src/components/KeyValueGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type ReactNode } from "react";
import { Fragment, type ReactNode } from "react";

/**
* KeyValueGrid renders semantic label/value pairs using a <dl>.
Expand All @@ -9,10 +9,10 @@ export function KeyValueGrid({ rows }: { rows: Row[] }) {
return (
<dl className="grid grid-cols-[max-content_1fr] gap-x-6 gap-y-2 text-sm">
{rows.map((r, i) => (
<div key={i} className="contents">
<dt className="text-zinc-500">{r.label}</dt>
<dd className="break-all">{r.value}</dd>
</div>
<Fragment key={i}>
<dt className="text-zinc-500" aria-label={typeof r.label === "string" ? r.label : undefined}>{r.label}</dt>
<dd className="break-all" aria-label={typeof r.value === "string" || typeof r.value === "number" ? String(r.value) : undefined}>{r.value}</dd>
</Fragment>
))}
</dl>
);
Expand Down
6 changes: 2 additions & 4 deletions src/components/__tests__/ConfirmDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -230,11 +230,10 @@ describe("ConfirmDialog", () => {
render(<ConfirmDialogHarness dismissOnBackdrop={false} />);

const { dialog, cancelButton } = openDialog();
const backdrop = dialog.parentElement as HTMLElement;

const onCancelSpy = jest.spyOn(cancelButton, "click");

fireEvent.mouseDown(backdrop);
fireEvent.mouseDown(dialog);
expect(screen.getByRole("dialog", { name: /delete project/i })).toBeInTheDocument();

onCancelSpy.mockRestore();
Expand All @@ -244,9 +243,8 @@ describe("ConfirmDialog", () => {
render(<ConfirmDialogHarness dismissOnBackdrop={true} />);

const { dialog } = openDialog();
const backdrop = dialog.parentElement as HTMLElement;

fireEvent.mouseDown(backdrop);
fireEvent.mouseDown(dialog);

expect(screen.queryByRole("dialog", { name: /delete project/i })).not.toBeInTheDocument();
});
Expand Down
8 changes: 7 additions & 1 deletion src/lib/validateNumber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@ const DEFAULT_POSITIVE_MESSAGE = "requests must be a positive integer";
* Rejected examples: "", "-1", "-0", "1.5", "1e2" (non-integer), "-0.1"
*/
export function parseNonNegativeInt(input: string): ParseResult {
if (input.trim() === "") {
return { ok: false, message: DEFAULT_NON_NEGATIVE_MESSAGE };
}
const n = Number(input);
if (!Number.isInteger(n) || n < 0) {
if (!Number.isInteger(n) || n < 0 || Object.is(n, -0)) {
return { ok: false, message: DEFAULT_NON_NEGATIVE_MESSAGE };
}
return { ok: true, value: n };
Expand All @@ -38,6 +41,9 @@ export function parseNonNegativeInt(input: string): ParseResult {
* Rejected examples: "", "0", "-1", "1.5", "-0.1"
*/
export function parsePositiveInt(input: string): ParseResult {
if (input.trim() === "") {
return { ok: false, message: DEFAULT_POSITIVE_MESSAGE };
}
const n = Number(input);
if (!Number.isInteger(n) || n <= 0) {
return { ok: false, message: DEFAULT_POSITIVE_MESSAGE };
Expand Down
Loading