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
132 changes: 132 additions & 0 deletions frontend/docs/pr/chideraisiguzor-1340-1341-1349-1351.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# frontend: idempotency keys, request validation, Lighthouse budget, Statistics

Four issues from the PredictIQ frontend rebuild backlog, all in the frontend
package.

## #1340 - Idempotency-key generation for mutating requests

**What existed:** `public-client.ts` / `admin-client.ts` sent POST/DELETE
bodies with no idempotency header, so an automatic network retry (or a failed
double-click guard) on bet placement could create a duplicate. The backend
already honours an `Idempotency-Key` header (documented in the generated
OpenAPI schema, `maxLength: 128`).

**The delta:**
- New `src/lib/api/idempotency.ts`: `newIdempotencyKey()` (uses
`crypto.randomUUID()`, falls back to a `getRandomValues` v4 UUID, then to a
timestamp+random token) and `isValidIdempotencyKey()`.
- `request()` resolves an idempotency key **once, outside the retry loop**, so
every automatic retry of one logical submission reuses it, while each new
`request()` call (a genuinely new user submission) gets a fresh key.
- `RequestOptions.idempotencyKey?: string | true` - `true` auto-generates,
a string lets a caller pin one across calls (the double-click-guard case).
- `placeBet` now takes an options object and defaults `idempotencyKey` to
`true`; `newsletterSubscribe` opts in as well.

**Tests:** `src/lib/api/__tests__/idempotency.test.ts` - key shape/uniqueness,
header attached, **same key reused across a 429-then-200 retry**, fresh key per
new call, caller-supplied key honoured, and a stateful "deduping backend" mock
proving a retry with the same key does not double-commit.

## #1341 - Validate request bodies against the generated schema at the boundary

**What existed:** malformed bodies went straight to the server and came back as
an opaque 400.

**The delta:**
- New `src/lib/api/requestSchemas.ts`: Zod schemas mirroring the generated
OpenAPI request bodies (`newsletterSubscribe`, e-mail requests, GDPR export,
admin e-mail test, `placeBet`). All `.loose()` so legitimately-optional and
forward-compatible extra fields are never rejected. A compile-time
`__contract` ties each schema to `components['schemas']` so backend drift
breaks `tsc`.
- `RequestOptions.bodySchema?: ZodType`; when set, `request()` runs
`safeParse` before fetch and throws
`ApiError(..., 0, 'CLIENT_VALIDATION_ERROR', { issues })` listing the
offending fields.
- Endpoints with a known body shape pass their schema.

**Tests:** `src/lib/api/__tests__/request-validation.test.ts` - schema
accept/reject cases, and boundary tests proving a malformed body rejects
**locally with `fetch` never called** while a well-formed body reaches the
network. One line of `client.test.ts` updated: its 400-handling test now uses a
well-formed address (an `'invalid'` one is now intercepted client-side).

## #1349 - Lighthouse performance budget for the landing page

**What existed:** `scripts/lighthouse-audit.js` checked only category scores,
audited `http://localhost:3000` with no explicit route, and never wrote the
`lighthouse-latest.json` that `.github/workflows/accessibility.yml`'s
"Check Lighthouse score" step reads.

**The delta:**
- Core Web Vitals budget (LCP 2500 ms, TBT 300 ms, CLS 0.1) added to
`performance/config/thresholds.json` under `lighthouse.budgets`.
- New pure `evaluateMetricBudgets(audits, budgets)` compares the audited
metrics against the budget and returns failures/passes; a missing metric is
a failure; `_`-prefixed doc keys are skipped.
- The script now audits the landing route explicitly
(`TEST_URL` + `LIGHTHOUSE_PATH`, default `/`), writes `lighthouse-latest.json`,
and exits non-zero when **any category is below threshold or any metric is
over budget** - so `npm run lighthouse` fails the build on a slow landing
page. `lighthouse`/`chrome-launcher` are now lazy `require`s so the module's
pure helpers can be unit-tested without launching Chrome.

**Tests:** `scripts/__tests__/lighthouse-audit.test.js` - budget pass, LCP over
budget fails, CLS over budget fails, missing metric fails, doc-key ignored,
`loadThresholds()` reads the config.

## #1351 - Recreate the Statistics component on the shared `useAsync` hook

**What existed:** `src/components/Statistics.tsx` and
`src/app/statistics/page.tsx` referenced a removed `useAsync` contract
(`{ loading, execute }`) and did not compile / render; `Statistics.tsx`
rendered `N/A` for any absent field.

**The delta:**
- `Statistics.tsx` rewritten on the current `useAsync` contract
(`{ data, status, error, retry }`). Tiles are data-driven from a
`METRIC_TILES` list: **Total Markets, Total Volume, Active Markets, Resolved
Markets** (every field the backend `Statistics` struct returns).
- Every value goes through `toNumber()` which coerces a number, a decimal
string (`total_volume` is an exact decimal string on the wire), or an absent
field to a finite number, **defaulting to `0`** - a tile is never blank or
`undefined`.
- `statistics/page.tsx` minimally adapted to the same contract (`loading`
derived from `status`, retry via `retry()`); its filters/export dashboard is
otherwise untouched.
- Issue asks for "#14's Card primitive" - no Card primitive exists in the repo
yet, so the existing `.stat-item` markup is kept.

**Tests:** `src/components/__tests__/Statistics.test.tsx` - real data from a
mocked `/api/v1/statistics` (string `total_volume`, unknown field ignored),
and an **all-zero/empty response** test asserting every tile renders `0` / `$0`.

## Verification

From `frontend/`:

```
./node_modules/.bin/jest src/components/__tests__/Statistics.test.tsx \
src/lib/api/__tests__/idempotency.test.ts \
src/lib/api/__tests__/request-validation.test.ts \
src/lib/api/__tests__/client.test.ts \
scripts/__tests__/lighthouse-audit.test.js
# Test Suites: 5 passed, Tests: 106 passed
```

## Pre-existing breakage (not touched)

`main` is currently mid-migration to the new `useAsync` contract: `tsc --noEmit`
reports ~351 errors, mostly other consumers still on the old
`{ loading, execute }` API (`markets/page.tsx`, `MarketDetailView.tsx`, the
dispute page, `admin/content/page.tsx`). Only the two files named in #1351 are
fixed here; the count drops (360 -> 351) and no new errors are introduced in
touched files. `src/components/__tests__/LandingPage.accessibility.test.tsx`
also has 6 failing form-alert assertions unrelated to this work. Per the
contribution guidance these are left for their own issues.

Closes #1340
Closes #1341
Closes #1349
Closes #1351
83 changes: 83 additions & 0 deletions frontend/scripts/__tests__/lighthouse-audit.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
const path = require('path');
const { evaluateMetricBudgets, loadThresholds } = require('../lighthouse-audit');

/**
* #1349 — `npm run lighthouse` must fail the build when the landing page's
* LCP (or CLS / TBT) exceeds the configured budget. The Chrome run itself
* can't execute in Jest, so the pass/fail decision is factored into the pure
* `evaluateMetricBudgets` helper and asserted here.
*/

const BUDGETS = {
'largest-contentful-paint': 2500,
'total-blocking-time': 300,
'cumulative-layout-shift': 0.1,
};

function auditsWith({ lcp, tbt, cls }) {
return {
'largest-contentful-paint': { numericValue: lcp },
'total-blocking-time': { numericValue: tbt },
'cumulative-layout-shift': { numericValue: cls },
};
}

describe('evaluateMetricBudgets', () => {
it('passes when every metric is within budget', () => {
const { failures, passes } = evaluateMetricBudgets(
auditsWith({ lcp: 1800, tbt: 120, cls: 0.02 }),
BUDGETS,
);
expect(failures).toHaveLength(0);
expect(passes.map((p) => p.id).sort()).toEqual(
['cumulative-layout-shift', 'largest-contentful-paint', 'total-blocking-time'],
);
});

it('fails when LCP exceeds its budget', () => {
const { failures } = evaluateMetricBudgets(
auditsWith({ lcp: 4200, tbt: 100, cls: 0.01 }),
BUDGETS,
);
expect(failures).toHaveLength(1);
expect(failures[0]).toMatchObject({ id: 'largest-contentful-paint', budget: 2500, actual: 4200 });
});

it('fails when CLS exceeds its budget', () => {
const { failures } = evaluateMetricBudgets(
auditsWith({ lcp: 1000, tbt: 50, cls: 0.35 }),
BUDGETS,
);
expect(failures.map((f) => f.id)).toContain('cumulative-layout-shift');
});

it('treats a metric missing from the report as a failure', () => {
const { failures } = evaluateMetricBudgets({}, BUDGETS);
expect(failures).toHaveLength(3);
expect(failures.every((f) => f.actual === null)).toBe(true);
});

it('ignores `_`-prefixed documentation keys in the budget map', () => {
const { failures, passes } = evaluateMetricBudgets(
auditsWith({ lcp: 1000, tbt: 50, cls: 0.01 }),
{ ...BUDGETS, _comment: 'docs' },
);
expect(failures).toHaveLength(0);
expect(passes).toHaveLength(3);
});
});

describe('loadThresholds', () => {
it('reads the landing-page Core Web Vitals budget from performance/config/thresholds.json', () => {
const { budgets } = loadThresholds();
expect(budgets['largest-contentful-paint']).toBe(2500);
expect(budgets['total-blocking-time']).toBe(300);
expect(budgets['cumulative-layout-shift']).toBe(0.1);
});

it('points at the repo-level performance config', () => {
// Guards against the reports-dir / config-path relativity regressing.
const configPath = path.join(__dirname, '../../../performance/config/thresholds.json');
expect(require('fs').existsSync(configPath)).toBe(true);
});
});
Loading
Loading