Files
portal/src/mocks/handlers.ts
Sharang Parnerkar 0797f8f99c
ci / test (pull_request) Failing after 4m54s
ci / shared (pull_request) Successful in 11s
ci / e2e (pull_request) Has been skipped
ci / image (pull_request) Has been skipped
feat(portal): M10.2 — MSW handlers + ToastHost + InviteButton end-to-end
Closes out the design pass with the missing piece: a real client-side
mock-API pipeline so the write-path CTAs the design shows (invite a
teammate, run a scan, kick off a workflow test, request reactivation)
actually do something visible without a backend.

* `public/mockServiceWorker.js` — generated by `pnpm exec msw init`.
* `src/mocks/handlers.ts` — POST handlers for `/api/team/invites`,
  `/api/scans`, `/api/workflows/:id/test`, `/api/billing/reactivate`.
  Each returns the design's mono status-code header
  (`201 · invite.created`, `202 · scan.queued`, etc.) so the toast
  surface reads identical to the handoff. A `x-bp-tenant-status` hint
  header lets the same handler respond 402 (frozen) or 410 (archived)
  without needing a real session.
* `src/mocks/browser.ts` — thin `setupWorker(...handlers)` wrapper,
  imported lazily so prod bundles don't pull MSW.
* `src/components/portal/MockWorker.tsx` — client component that boots
  the worker only when `window.__BP_MOCK_API__` is true (set by
  `[slug]/layout` when `BP_DEV_FIXTURE` is on the server). Real Auth.js
  builds skip the worker entirely.
* `src/components/portal/ToastHost.tsx` — global bottom-right toast
  queue, mounted in `[slug]/layout`. Emits via a custom event so any
  client component can call `toast({ msg, code })` without prop-drilling.
* `src/components/portal/InviteButton.tsx` — first live write affordance.
  Modal with email + role-segmented buttons, POSTs to `/api/team/invites`
  with the tenant-status hint header, surfaces 201/402/410 differently.
  Wired into the Team page.
* `src/middleware.ts` — added `mockServiceWorker.js` to the matcher
  exclusion list so the host-rewrite doesn't 404 the worker script.

Verified end-to-end via Playwright: SW registers at the root scope,
click Invite member → fill email → Send invitation → MSW intercepts →
toast "Invitation sent · 201 · invite.created" → modal closes.

This closes the last open M10.2 task. Branch is ready to review/merge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 16:11:18 +02:00

120 lines
4.1 KiB
TypeScript

// Browser-side mock API for dev-fixture mode.
//
// Wired into the page via `src/mocks/MockWorker.tsx`. Only initialised when
// `BP_DEV_FIXTURE` is set on the server (the env value is forwarded to the
// client via a window global). Production builds never start the worker.
//
// Today's surface is the small set of write paths the design shows. They
// don't persist — every response is synthesised so the same click always
// looks the same. When the real platform endpoints exist, drop the
// matching handler from this file.
import { http, HttpResponse, delay } from "msw";
type InvitePayload = {
email?: string;
role?: string;
};
type TestRunPayload = {
workflowId?: string;
};
// ---- Frozen-tenant guard --------------------------------------------------
// In dev-fixture mode the tenant status is encoded in a cookie or a
// window global; for now we read a hint from a custom header that the
// caller sets, so the same mock handler can respond 402 or 201 depending
// on which fixture is currently active.
function isFrozen(req: Request): boolean {
return req.headers.get("x-bp-tenant-status") === "frozen";
}
function isArchived(req: Request): boolean {
return req.headers.get("x-bp-tenant-status") === "archived";
}
function archivedResponse() {
return HttpResponse.json(
{ error: "tenant_archived", message: "Tenant retention window closed." },
{ status: 410 },
);
}
function frozenResponse() {
return HttpResponse.json(
{
error: "tenant_frozen",
message: "Tenant is read-only. Re-activate to resume writes.",
},
{ status: 402 },
);
}
export const handlers = [
// ---- /api/team/invites -------------------------------------------------
http.post("/api/team/invites", async ({ request }) => {
if (isArchived(request)) return archivedResponse();
if (isFrozen(request)) return frozenResponse();
await delay(280);
const body = (await request.json().catch(() => ({}))) as InvitePayload;
if (!body.email || !body.email.includes("@")) {
return HttpResponse.json({ error: "invalid_email" }, { status: 400 });
}
const role = body.role ?? "USER";
return HttpResponse.json(
{
id: "inv-" + Math.random().toString(36).slice(2, 9),
email: body.email,
role,
status: "invited",
created_at: new Date().toISOString(),
},
{ status: 201, headers: { "x-bp-status-code": "201 · invite.created" } },
);
}),
// ---- /api/scans -------------------------------------------------------
http.post("/api/scans", async ({ request }) => {
if (isArchived(request)) return archivedResponse();
if (isFrozen(request)) return frozenResponse();
await delay(420);
return HttpResponse.json(
{
id: "scan-" + Math.random().toString(36).slice(2, 9),
status: "queued",
queued_at: new Date().toISOString(),
},
{ status: 202, headers: { "x-bp-status-code": "202 · scan.queued" } },
);
}),
// ---- /api/workflows/:id/test -----------------------------------------
http.post("/api/workflows/:id/test", async ({ request, params }) => {
if (isArchived(request)) return archivedResponse();
if (isFrozen(request)) return frozenResponse();
await delay(180);
return HttpResponse.json(
{
workflow_id: params.id,
run_id: "wfr-" + Math.random().toString(36).slice(2, 9),
status: "started",
} satisfies Record<string, unknown> & { workflow_id: unknown },
{ status: 202, headers: { "x-bp-status-code": "202 · workflow.test" } },
);
}),
// ---- /api/billing/reactivate ----------------------------------------
http.post("/api/billing/reactivate", async ({ request }) => {
if (isArchived(request)) return archivedResponse();
await delay(320);
return HttpResponse.json(
{ status: "pending", contact: "billing@breakpilot.eu" },
{
status: 202,
headers: { "x-bp-status-code": "202 · reactivation.requested" },
},
);
}),
];
// Silence unused-type warnings for payloads we don't fully validate.
export type { InvitePayload, TestRunPayload };