POC: bring back MSW to remove the pattern of vi.mock (#46439)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Right now our tests for API mocking is using vi.mock and mocking that
query or fetch handler. This is not the right approach IMO, 2 years ago
@jordienr added MSW with some very powerful helpers. The idea is to move
component test that rely on API using MSW within ViteTest. Principles
are simple:
- Mock API responses
- Mount your component that uses API responses
- Tests and assert on UI 
- Added Skill for Clanker

This pattern is 100 times better than what we have

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Tests**
* Expanded and strengthened test suites for secrets, org lookup, support
flows, OAuth auth, and onboarding; mocks now use contract-backed
responses for more realistic coverage.

* **Documentation**
* Added a comprehensive guide describing a standardized pattern for
component tests that mock network requests.

* **Chores**
* Improved test helpers, typing for API mocks, and test runner
configuration for more reliable and maintainable tests.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46439?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <alaister@users.noreply.github.com>
Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-05-28 12:58:50 +00:00
1 parent c595a22eb3
commit 6236ee9ef9
11 files changed
+645 -66

No files matched your search

@@ -0,0 +1,294 @@
---
name: studio-mock-api-tests
description: Component tests for Supabase Studio that mock API requests at the
network layer with MSW. Use when writing or reviewing a component test that
exercises a React Query hook or mutation, or when migrating an existing
test away from vi.mock('@/data/...'). Covers the customRender + addAPIMock
template and the jsdom/MSW gotchas that cost real debugging time.
---
# Studio MSW component tests
Mount a Studio component, intercept its network calls with MSW, assert
what renders and what gets sent. The infrastructure is already wired up —
this skill is the working template plus the gotchas.
## When to use
- The component (or any descendant it renders) calls a React Query hook
or mutation that hits `/platform/...`, `/v1/...`, or another endpoint
in `apps/studio/data/api.d.ts`.
- You'd otherwise be tempted to write `vi.mock('@/data/some-query', ...)`.
**Don't.** Mock the network instead — see "Why not vi.mock" below.
If the component is purely presentational with no data fetching, you
don't need MSW; render and assert directly.
## The template
```tsx
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { mockAnimationsApi } from 'jsdom-testing-mocks'
import { HttpResponse } from 'msw'
import { describe, expect, test, vi } from 'vitest'
import { MyComponent } from './MyComponent'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
// Needed if the component renders inside a Sheet, Modal, Popover, or
// anything else built on Radix that uses Web Animations.
mockAnimationsApi()
describe('MyComponent', () => {
test('renders rows from the API', async () => {
addAPIMock({
method: 'get',
path: '/platform/organizations',
response: () =>
HttpResponse.json<OrganizationResponse[]>([
{
/* ... */
},
]),
})
customRender(<MyComponent />)
expect(await screen.findByText('Acme')).toBeInTheDocument()
})
})
```
That's the whole pattern. Server lifecycle (`listen`/`resetHandlers`/
`close`) is handled by `apps/studio/tests/vitestSetup.ts` — handlers
registered via `addAPIMock` are scoped to the current test.
## Gotchas that will eat your afternoon
### 1. Path params use `:slug`, not `{slug}`
`addAPIMock` is typed from the OpenAPI `paths`, but path params are
remapped to MSW's `:param` format. Autocomplete will guide you, but if
typecheck reports the path isn't assignable, you're using the OpenAPI
`{slug}` form.
```ts
// ❌ TypeScript error, MSW won't match
path: '/platform/organizations/{slug}/projects'
// ✅ Correct
path: '/platform/organizations/:slug/projects'
```
### 2. Use `HttpResponse.json`, not `new HttpResponse`
For success responses, always go through `HttpResponse.json` — even for
204/201-no-content endpoints. A raw `new HttpResponse(null, { status: 201 })`
returns no content-type, and `openapi-fetch` can hang the mutation flow,
which silently breaks `onSuccess` callbacks.
```ts
// ❌ Mutation onSuccess silently never fires
response: () => new HttpResponse(null, { status: 201 })
// ✅ Works (pass the OpenAPI body shape explicitly — see gotcha #8)
response: () => HttpResponse.json<MyResponse>({}, { status: 201 })
```
### 3. Submit buttons in Sheets/Modals need `fireEvent.click`
The convention `<Button form={FORM_ID} htmlType="submit" />` (button
outside the form, associated by id) doesn't reliably trigger submission
under `userEvent.click` in jsdom. Use `fireEvent.click` for the submit
button. Continue to use `userEvent.type` for inputs.
```ts
await userEvent.type(screen.getByPlaceholderText('value'), 'hello')
fireEvent.click(await screen.findByRole('button', { name: 'Save' }))
```
### 4. Profile-gated queries need a `profileContext`
Many hooks (`useOrganizationsQuery`, anything in `data/projects/`,
anything that calls `useProfile`) refuse to fire until a profile is
loaded. Pass one explicitly:
```ts
import type { ProfileContextType } from '@/lib/profile'
const PROFILE_CONTEXT: ProfileContextType = {
profile: {
id: 1,
auth0_id: 'auth0|test',
gotrue_id: 'gotrue-test',
username: 'testuser',
primary_email: 'test@example.com',
first_name: null,
last_name: null,
mobile: null,
is_alpha_user: false,
is_sso_user: false,
disabled_features: [],
free_project_limit: null,
},
error: null,
isLoading: false,
isError: false,
isSuccess: true,
}
customRender(<MyComponent />, { profileContext: PROFILE_CONTEXT })
```
### 5. `useParams` is globally mocked to `{ ref: 'default' }`
You don't need to mock the Next router for project-scoped components.
Just use `'default'` as the project ref in your mock paths:
`/v1/projects/default/secrets`, `/platform/projects/default/...`. If
you need a different ref, override with `routerMock.setCurrentUrl(...)`
(see `apps/studio/tests/lib/route-mock.ts`).
### 6. Unhandled requests fail loudly — mock every endpoint a render triggers
`mswServer.listen({ onUnhandledRequest: 'error' })` is set globally. If a
component (or any child it renders) fires an unmocked request, you'll see
MSW errors in stderr and likely flaky behavior. Cards, lists, and details
panels often fire nested queries (e.g. `OrganizationCard` calls
`useOrgProjectsInfiniteQuery`) — read what the rendered subtree does and
mock all of it, or stub it with `vi.mock` for nested components only.
### 7. Don't put query strings in the handler `path`
`addAPIMock` accepts `?foo=bar` suffixes via `TrimQueryParams`, but the
helper strips them before matching. MSW v2 doesn't match query params via
path strings — read them inside the resolver instead:
```ts
addAPIMock({
method: 'get',
path: '/platform/projects',
response: ({ request }) => {
const limit = new URL(request.url).searchParams.get('limit')
// ...
},
})
```
### 8. Always pass an explicit generic to `HttpResponse.json`
`addAPIMock`'s resolver is typed against the OpenAPI success body (and the
standard `{ message: string }` error envelope, exported as `APIErrorBody`).
But MSW's `HttpResponse.json` uses `NoInfer`, so the body type doesn't
narrow from context. Pass the expected shape explicitly — it doubles as a
self-documenting contract assertion:
```ts
import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw'
response: () => HttpResponse.json<OrganizationResponse[]>([...])
response: () =>
HttpResponse.json<APIErrorBody>({ message: 'Boom' }, { status: 500 })
```
A mock that drifts from the contract (wrong envelope, missing fields,
stale enum values) now fails at compile time, not at runtime. The cost is
one type annotation per resolver — well worth it.
For mocks at the network boundary, also prefer `createMockOrganizationResponse`
(returns the raw OpenAPI `OrganizationResponse`) over `createMockOrganization`
(which extends with frontend-derived `managed_by` / `partner_id` that the
query layer attaches). Same pattern applies to any type that's a frontend
extension of an OpenAPI schema: build a `createMockXResponse` helper that
returns the raw API shape.
## Prefer asserting on UI state
MSW's own best-practices doc explicitly recommends asserting on what
renders, not on whether a handler was called. The "did the form
submit?" question is best answered by `expect(onClose).toHaveBeenCalled()`
or by `findByText('Saved')` — not by spying on the resolver.
There's one legitimate exception: **the request body itself is the
contract you care about**, and the server's reply doesn't reflect it
back. Bulk-create endpoints (like `POST /v1/projects/:ref/secrets`) are
the canonical case — 201 with no body, so the only way to verify the
shape sent is to capture it:
```ts
const requests: Array<{ ref: string | undefined; body: unknown }> = []
addAPIMock({
method: 'post',
path: '/v1/projects/:ref/secrets',
response: async ({ request, params }) => {
requests.push({ ref: params.ref as string | undefined, body: await request.json() })
return HttpResponse.json<CreateSecretsResponse>({}, { status: 201 })
},
})
// ... drive the UI ...
expect(requests).toEqual([{ ref: 'default', body: [{ name: 'API_KEY', value: 'new-value' }] }])
```
When in doubt, assert on the UI first; reach for request capture only
when the UI doesn't observably encode the contract.
## Debugging an MSW test
If a request isn't being matched, wire up MSW's lifecycle events at the
top of the test file (or temporarily in `msw.ts`):
```ts
import { mswServer } from '@/tests/lib/msw'
mswServer.events.on('request:unhandled', ({ request }) => {
console.log('[MSW] UNHANDLED:', request.method, request.url)
})
mswServer.events.on('response:mocked', ({ request, response }) => {
console.log('[MSW] MATCHED:', request.method, request.url, response.status)
})
```
`request:start` is already wired in `msw.ts`. Add `request:unhandled` and
`response:mocked` locally when a test misbehaves — usually surfaces a
path-param mismatch or a nested query you forgot to mock.
## Why not `vi.mock('@/data/...')`
It bypasses the network boundary, so:
- It hides real bugs: a renamed query key or a changed request payload
passes the test, then breaks in production.
- It doesn't exercise React Query's caching, retry, or invalidation
paths — `onMutate`, `onSuccess`, and `onError` callbacks won't run as
they do in real life. ([tkdodo.eu/blog/testing-react-query](https://tkdodo.eu/blog/testing-react-query))
- It drifts independently from the OpenAPI types — handlers stay in sync,
module-level mocks don't.
Reach for `vi.mock` only for non-network concerns: a heavy child
component (e.g. a Monaco editor) you want to stub, or a `common`-package
hook with global state.
## Further reading
- [TkDodo — Testing React Query](https://tkdodo.eu/blog/testing-react-query) —
canonical reference for the principles behind everything in this skill.
- [MSW best practices: structuring handlers](https://mswjs.io/docs/best-practices/structuring-handlers/)
and [overriding network behavior](https://mswjs.io/docs/best-practices/network-behavior-overrides/) —
the baseline-handlers + per-test-`server.use()` pattern.
- [MSW best practices: avoid request assertions](https://mswjs.io/docs/best-practices/avoid-request-assertions/) —
the source of the "assert on UI state" guidance above.
## Codebase references
| What | Where |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| Query-only example (loading, error, success) | `apps/studio/components/interfaces/Organization/OrgNotFound.test.tsx` |
| Mutation example (form, payload assertion) | `apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EditSecretSheet.test.tsx` |
| SQL-via-pg-meta example (POST resolver branch on `query` body) | `apps/studio/components/interfaces/Integrations/Vault/Secrets/__tests__/EditSecretModal.test.tsx` |
| `addAPIMock` source | `apps/studio/tests/lib/msw.ts` |
| `customRender` source | `apps/studio/tests/lib/custom-render.tsx` |
| Global handlers + lifecycle | `apps/studio/tests/lib/msw-global-api-mocks.ts`, `apps/studio/tests/vitestSetup.ts` |
| Related skills | `studio-testing` (when to write a component test at all), `studio-queries` (hook conventions), `vitest` |
@@ -0,0 +1,69 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { mockAnimationsApi } from 'jsdom-testing-mocks'
import { HttpResponse } from 'msw'
import { describe, expect, test, vi } from 'vitest'
import { EditSecretSheet } from './EditSecretSheet'
import type { ProjectSecret } from '@/data/secrets/secrets-query'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
mockAnimationsApi()
const SECRET: ProjectSecret = { name: 'API_KEY', value: '' }
const renderSheet = (overrides: { onClose?: () => void } = {}) => {
const onClose = overrides.onClose ?? vi.fn()
customRender(<EditSecretSheet visible secret={SECRET} onClose={onClose} />)
return { onClose }
}
describe('EditSecretSheet', () => {
test('keeps Save disabled until a value is entered', async () => {
renderSheet()
const save = await screen.findByRole('button', { name: 'Save' })
expect(save).toBeDisabled()
await userEvent.type(screen.getByPlaceholderText('my-secret-value'), 'new-value')
await waitFor(() => expect(save).toBeEnabled())
})
test('posts the secret and closes on success', async () => {
const requests: Array<{ ref: string | undefined; body: unknown }> = []
addAPIMock({
method: 'post',
path: '/v1/projects/:ref/secrets',
response: async ({ request, params }) => {
requests.push({ ref: params.ref as string | undefined, body: await request.json() })
return HttpResponse.json({}, { status: 201 })
},
})
const { onClose } = renderSheet()
await userEvent.type(screen.getByPlaceholderText('my-secret-value'), 'new-value')
fireEvent.click(await screen.findByRole('button', { name: 'Save' }))
await waitFor(() => expect(onClose).toHaveBeenCalledOnce())
expect(requests).toEqual([{ ref: 'default', body: [{ name: 'API_KEY', value: 'new-value' }] }])
})
test('keeps the sheet open when the mutation fails', async () => {
addAPIMock({
method: 'post',
path: '/v1/projects/:ref/secrets',
response: () => HttpResponse.json({ message: 'Something exploded' }, { status: 500 }),
})
const { onClose } = renderSheet()
await userEvent.type(screen.getByPlaceholderText('my-secret-value'), 'new-value')
const save = await screen.findByRole('button', { name: 'Save' })
fireEvent.click(save)
await waitFor(() => expect(save).not.toBeDisabled())
expect(onClose).not.toHaveBeenCalled()
})
})
@@ -47,7 +47,6 @@ describe(`EditSecretModal`, () => {
addAPIMock({
method: `post`,
path: `/platform/pg-meta/:ref/query`,
// @ts-expect-error this path erroneously has a `never` return type when it should be `unknown` since it executes a SQL query
response: async ({ request }) => {
const body = (await request.json()) as { query: string }
const query = body.query
@@ -0,0 +1,92 @@
import { screen } from '@testing-library/react'
import { platformComponents as components } from 'api-types'
import { HttpResponse } from 'msw'
import { describe, expect, test } from 'vitest'
import { OrgNotFound } from './OrgNotFound'
import type { ProfileContextType } from '@/lib/profile'
import { createMockOrganizationResponse } from '@/tests/helpers'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw'
type OrganizationResponse = components['schemas']['OrganizationResponse']
type OrganizationProjectsResponse = components['schemas']['OrganizationProjectsResponse']
const PROFILE_CONTEXT: ProfileContextType = {
profile: {
id: 1,
auth0_id: 'auth0|test',
gotrue_id: 'gotrue-test',
username: 'testuser',
primary_email: 'test@example.com',
first_name: null,
last_name: null,
mobile: null,
is_alpha_user: false,
is_sso_user: false,
disabled_features: [],
free_project_limit: null,
},
error: null,
isLoading: false,
isError: false,
isSuccess: true,
}
const mockEmptyProjectsResponse = () => {
addAPIMock({
method: 'get',
path: '/platform/organizations/:slug/projects',
response: () =>
HttpResponse.json<OrganizationProjectsResponse>({
pagination: { count: 0, limit: 96, offset: 0 },
projects: [],
}),
})
}
describe('OrgNotFound', () => {
test('renders the not-found admonition with the slug', async () => {
addAPIMock({
method: 'get',
path: '/platform/organizations',
response: () => HttpResponse.json<OrganizationResponse[]>([]),
})
customRender(<OrgNotFound slug="ghost-org" />, { profileContext: PROFILE_CONTEXT })
expect(await screen.findByText('Organization not found')).toBeInTheDocument()
expect(screen.getByText('ghost-org')).toBeInTheDocument()
})
test('renders an organization card for each org returned from the API', async () => {
addAPIMock({
method: 'get',
path: '/platform/organizations',
response: () =>
HttpResponse.json<OrganizationResponse[]>([
createMockOrganizationResponse({ slug: 'acme-prod', name: 'Acme Production' }),
createMockOrganizationResponse({ slug: 'acme-dev', name: 'Acme Development' }),
]),
})
mockEmptyProjectsResponse()
customRender(<OrgNotFound slug="ghost-org" />, { profileContext: PROFILE_CONTEXT })
expect(await screen.findByText('Acme Production')).toBeInTheDocument()
expect(screen.getByText('Acme Development')).toBeInTheDocument()
})
test('shows an error admonition when the organizations query fails', async () => {
addAPIMock({
method: 'get',
path: '/platform/organizations',
response: () =>
HttpResponse.json<APIErrorBody>({ message: 'Boom from the backend' }, { status: 500 }),
})
customRender(<OrgNotFound slug="ghost-org" />, { profileContext: PROFILE_CONTEXT })
expect(await screen.findByText('Failed to load organizations')).toBeInTheDocument()
})
})
@@ -1,16 +1,19 @@
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { platformComponents as components } from 'api-types'
import { FeatureFlagContext } from 'common'
import { HttpResponse } from 'msw'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { RedeemCreditsScreen } from './RedeemCredits'
import type { ProfileContextType } from '@/lib/profile'
import { createMockOrganization } from '@/tests/helpers'
import { createMockOrganizationResponse } from '@/tests/helpers'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
import { routerMock } from '@/tests/lib/route-mock'
type OrganizationResponse = components['schemas']['OrganizationResponse']
const { creditRedemptionProps } = vi.hoisted(() => ({
creditRedemptionProps: vi.fn(),
}))
@@ -52,7 +55,7 @@ const DEFAULT_PROFILE_CONTEXT: ProfileContextType = {
isSuccess: true,
}
const ORGANIZATION = createMockOrganization({
const ORGANIZATION = createMockOrganizationResponse({
id: 1,
name: 'Acme Production',
slug: 'acme-production',
@@ -82,7 +85,7 @@ describe('RedeemCreditsScreen', () => {
addAPIMock({
method: 'get',
path: '/platform/organizations',
response: () => HttpResponse.json([ORGANIZATION]),
response: () => HttpResponse.json<OrganizationResponse[]>([ORGANIZATION]),
})
renderScreen()
@@ -106,7 +109,7 @@ describe('RedeemCreditsScreen', () => {
addAPIMock({
method: 'get',
path: '/platform/organizations',
response: () => HttpResponse.json([ORGANIZATION]),
response: () => HttpResponse.json<OrganizationResponse[]>([ORGANIZATION]),
})
renderScreen()
@@ -128,7 +131,7 @@ describe('RedeemCreditsScreen', () => {
addAPIMock({
method: 'get',
path: '/platform/organizations',
response: () => HttpResponse.json([ORGANIZATION]),
response: () => HttpResponse.json<OrganizationResponse[]>([ORGANIZATION]),
})
renderScreen()
@@ -1,5 +1,6 @@
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { platformComponents as components } from 'api-types'
import dayjs from 'dayjs'
import { http, HttpResponse } from 'msw'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
@@ -9,21 +10,68 @@ import { SupportForm, SupportFormPage, SupportFormStatusButton } from '../Suppor
// End of third-party imports
import { API_URL, BASE_PATH } from '@/lib/constants'
import { createMockOrganization, createMockProject } from '@/tests/helpers'
import { createMockOrganizationResponse, createMockProject } from '@/tests/helpers'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock, mswServer } from '@/tests/lib/msw'
import { addAPIMock, mswServer, type APIErrorBody } from '@/tests/lib/msw'
import { createMockProfileContext } from '@/tests/lib/profile-helpers'
type ProjectDetailResponse = components['schemas']['ProjectDetailResponse']
type OrganizationProjectsResponse = components['schemas']['OrganizationProjectsResponse']
type OrganizationProjectsProject = OrganizationProjectsResponse['projects'][number]
type SendFeedbackResponse = components['schemas']['SendFeedbackResponse']
// Builders that return shapes matching the OpenAPI contract for endpoints
// the support form depends on. The test only exercises a few fields, but the
// constraints catch silent drift between mocks and the API.
const toProjectDetailResponse = (project: {
id: number
ref: string
name: string
organization_id: number
}): ProjectDetailResponse => ({
id: project.id,
ref: project.ref,
name: project.name,
organization_id: project.organization_id,
cloud_provider: 'AWS',
db_host: `db.${project.ref}.example.com`,
high_availability: false,
inserted_at: new Date().toISOString(),
integration_source: null,
is_branch_enabled: false,
is_physical_backups_enabled: false,
region: 'us-east-1',
restUrl: `https://${project.ref}.example.com/rest`,
status: 'ACTIVE_HEALTHY',
subscription_id: 'subscription-1',
updated_at: new Date().toISOString(),
})
const toOrganizationProject = (project: {
ref: string
name: string
}): OrganizationProjectsProject => ({
cloud_provider: 'AWS',
databases: [],
inserted_at: new Date().toISOString(),
integration_source: null,
is_branch: false,
name: project.name,
ref: project.ref,
region: 'us-east-1',
status: 'ACTIVE_HEALTHY',
})
type Screen = typeof screen
const mockOrganizations = [
createMockOrganization({
createMockOrganizationResponse({
id: 1,
slug: 'org-1',
name: 'Organization 1',
plan: { id: 'free', name: 'Free' },
}),
createMockOrganization({
createMockOrganizationResponse({
id: 2,
slug: 'org-2',
name: 'Organization 2',
@@ -421,8 +469,8 @@ describe('SupportFormPage', () => {
const { ref } = params as { ref: string }
const project = mockProjects.projects.find((candidate) => candidate.ref === ref)
return project
? HttpResponse.json(project)
: HttpResponse.json({ msg: 'Project not found' }, { status: 404 })
? HttpResponse.json<ProjectDetailResponse>(toProjectDetailResponse(project))
: HttpResponse.json<APIErrorBody>({ message: 'Project not found' }, { status: 404 })
},
})
@@ -461,8 +509,8 @@ describe('SupportFormPage', () => {
const paginated = sorted.slice(offset, offset + limit)
return HttpResponse.json({
projects: paginated,
return HttpResponse.json<OrganizationProjectsResponse>({
projects: paginated.map(toOrganizationProject),
pagination: {
count: projects.length,
limit,
@@ -706,7 +754,7 @@ describe('SupportFormPage', () => {
path: '/platform/feedback/send',
response: async ({ request }) => {
submitSpy(await request.json())
return HttpResponse.json({ ok: true })
return HttpResponse.json<SendFeedbackResponse>({ result: 'ok' })
},
})
@@ -751,7 +799,7 @@ describe('SupportFormPage', () => {
path: '/platform/feedback/send',
response: async ({ request }) => {
submitSpy(await request.json())
return HttpResponse.json({ ok: true })
return HttpResponse.json<SendFeedbackResponse>({ result: 'ok' })
},
})
@@ -795,7 +843,7 @@ describe('SupportFormPage', () => {
path: '/platform/feedback/send',
response: async ({ request }) => {
submitSpy(await request.json())
return HttpResponse.json({ ok: true })
return HttpResponse.json<SendFeedbackResponse>({ result: 'ok' })
},
})
@@ -898,7 +946,7 @@ describe('SupportFormPage', () => {
path: '/platform/feedback/send',
response: async ({ request }) => {
submitSpy(await request.json())
return HttpResponse.json({ ok: true })
return HttpResponse.json<SendFeedbackResponse>({ result: 'ok' })
},
})
@@ -990,7 +1038,7 @@ describe('SupportFormPage', () => {
path: '/platform/feedback/send',
response: async ({ request }) => {
submitSpy(await request.json())
return HttpResponse.json({ ok: true })
return HttpResponse.json<SendFeedbackResponse>({ result: 'ok' })
},
})
@@ -1013,8 +1061,8 @@ describe('SupportFormPage', () => {
const { ref } = params as { ref: string }
const project = mockProjects.projects.find((candidate) => candidate.ref === ref)
return project
? HttpResponse.json(project)
: HttpResponse.json({ msg: 'Project not found' }, { status: 404 })
? HttpResponse.json<ProjectDetailResponse>(toProjectDetailResponse(project))
: HttpResponse.json<APIErrorBody>({ message: 'Project not found' }, { status: 404 })
},
})
@@ -1190,7 +1238,7 @@ describe('SupportFormPage', () => {
response: async () => {
submitSpy()
await submission.promise
return HttpResponse.json({ ok: true })
return HttpResponse.json<SendFeedbackResponse>({ result: 'ok' })
},
})
@@ -1301,7 +1349,7 @@ describe('SupportFormPage', () => {
path: '/platform/feedback/send',
response: async ({ request }) => {
submitSpy(await request.json())
return HttpResponse.json({ ok: true })
return HttpResponse.json<SendFeedbackResponse>({ result: 'ok' })
},
})
@@ -1370,7 +1418,7 @@ describe('SupportFormPage', () => {
path: '/platform/feedback/send',
response: async ({ request }) => {
submitSpy(await request.json())
return HttpResponse.json({ ok: true })
return HttpResponse.json<SendFeedbackResponse>({ result: 'ok' })
},
})
@@ -1433,7 +1481,7 @@ describe('SupportFormPage', () => {
path: '/platform/feedback/send',
response: async ({ request }) => {
submitSpy(await request.json())
return HttpResponse.json({ ok: true })
return HttpResponse.json<SendFeedbackResponse>({ result: 'ok' })
},
})
@@ -1495,7 +1543,7 @@ describe('SupportFormPage', () => {
method: 'post',
path: '/platform/feedback/send',
response: async () => {
return HttpResponse.json({ message: errorMessage }, { status: 500 })
return HttpResponse.json<APIErrorBody>({ message: errorMessage }, { status: 500 })
},
})
@@ -1534,7 +1582,7 @@ describe('SupportFormPage', () => {
path: '/platform/feedback/send',
response: async ({ request }) => {
submitSpy(await request.json())
return HttpResponse.json({ ok: true })
return HttpResponse.json<SendFeedbackResponse>({ result: 'ok' })
},
})
@@ -1614,7 +1662,7 @@ describe('SupportFormPage', () => {
path: '/platform/feedback/send',
response: async ({ request }) => {
submitSpy(await request.json())
return HttpResponse.json({ ok: true })
return HttpResponse.json<SendFeedbackResponse>({ result: 'ok' })
},
})
@@ -1731,7 +1779,7 @@ describe('SupportFormPage', () => {
path: '/platform/feedback/send',
response: async ({ request }) => {
submitSpy(await request.json())
return HttpResponse.json({ ok: true })
return HttpResponse.json<SendFeedbackResponse>({ result: 'ok' })
},
})
@@ -1,5 +1,6 @@
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { platformComponents as components } from 'api-types'
import dayjs from 'dayjs'
import { HttpResponse } from 'msw'
import { describe, expect, test, vi } from 'vitest'
@@ -10,10 +11,14 @@ import {
} from '@/components/interfaces/ApiAuthorization/ApiAuthorization'
import type { ApiAuthorizationResponse } from '@/data/api-authorization/api-authorization-query'
import type { ProfileContextType } from '@/lib/profile'
import { createMockOrganization } from '@/tests/helpers'
import { createMockOrganizationResponse } from '@/tests/helpers'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
import type { Organization } from '@/types'
import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw'
type OrganizationResponse = components['schemas']['OrganizationResponse']
type GetOAuthAuthorizationResponse = components['schemas']['GetOAuthAuthorizationResponse']
type ApproveAuthorizationResponse = components['schemas']['ApproveAuthorizationResponse']
type DeclineAuthorizationResponse = components['schemas']['DeclineAuthorizationResponse']
// --- Fixtures ---
@@ -54,8 +59,12 @@ function createMockAuthResponse(
}
}
const DEFAULT_ORG = createMockOrganization({ name: 'My Org', slug: 'my-org' })
const SECOND_ORG = createMockOrganization({ id: 2, name: 'Second Org', slug: 'second-org' })
const DEFAULT_ORG = createMockOrganizationResponse({ name: 'My Org', slug: 'my-org' })
const SECOND_ORG = createMockOrganizationResponse({
id: 2,
name: 'Second Org',
slug: 'second-org',
})
// --- MSW helpers ---
@@ -66,21 +75,28 @@ function mockAuthEndpoint(authResponse: ApiAuthorizationResponse) {
addAPIMock({
method: 'get',
path: '/platform/oauth/authorizations/:id',
response: () => HttpResponse.json(authResponse),
// The frontend `ApiAuthorizationResponse` widens the OpenAPI shape
// (looser `registration_type`, nullable `icon`/`approved_at`), and the
// query layer casts via `data as ApiAuthorizationResponse`. Cast here to
// satisfy the network-boundary contract.
response: () =>
HttpResponse.json<GetOAuthAuthorizationResponse>(
authResponse as unknown as GetOAuthAuthorizationResponse
),
})
}
function mockOrgsEndpoint(orgs: Array<Organization> = [DEFAULT_ORG]) {
function mockOrgsEndpoint(orgs: OrganizationResponse[] = [DEFAULT_ORG]) {
addAPIMock({
method: 'get',
path: '/platform/organizations',
response: () => HttpResponse.json(orgs),
response: () => HttpResponse.json<OrganizationResponse[]>(orgs),
})
}
function mockBothEndpoints(
authResponse: ApiAuthorizationResponse = createMockAuthResponse(),
orgs: Array<Organization> = [DEFAULT_ORG]
orgs: OrganizationResponse[] = [DEFAULT_ORG]
) {
mockAuthEndpoint(authResponse)
mockOrgsEndpoint(orgs)
@@ -119,7 +135,7 @@ describe('ApiAuthorizationScreen', () => {
addAPIMock({
method: 'get',
path: '/platform/oauth/authorizations/:id',
response: () => new Promise(() => {}),
response: () => new Promise<never>(() => {}),
})
const { container } = renderScreen()
expect(screen.getByText('Loading...')).toBeInTheDocument()
@@ -131,7 +147,7 @@ describe('ApiAuthorizationScreen', () => {
addAPIMock({
method: 'get',
path: '/platform/oauth/authorizations/:id',
response: () => HttpResponse.json({ message: 'Not found' }, { status: 404 }),
response: () => HttpResponse.json<APIErrorBody>({ message: 'Not found' }, { status: 404 }),
})
renderScreen()
await screen.findByText('Failed to fetch details for API authorization request')
@@ -170,7 +186,7 @@ describe('ApiAuthorizationScreen', () => {
addAPIMock({
method: 'get',
path: '/platform/organizations',
response: () => new Promise(() => {}),
response: () => new Promise<never>(() => {}),
})
renderScreen()
await screen.findByText('Authorize API access for Test App')
@@ -183,7 +199,8 @@ describe('ApiAuthorizationScreen', () => {
addAPIMock({
method: 'get',
path: '/platform/organizations',
response: () => HttpResponse.json({ message: 'Server error' }, { status: 500 }),
response: () =>
HttpResponse.json<APIErrorBody>({ message: 'Server error' }, { status: 500 }),
})
renderScreen()
await screen.findByText('There was an error loading your organizations')
@@ -282,7 +299,9 @@ describe('ApiAuthorizationScreen', () => {
test('calls approve endpoint when Authorize button is clicked', async () => {
const user = userEvent.setup()
const approveHandler = vi.fn(() =>
HttpResponse.json({ url: 'https://redirect.example.com' })
HttpResponse.json<ApproveAuthorizationResponse>({
url: 'https://redirect.example.com',
})
)
mockBothEndpoints()
addAPIMock({
@@ -300,7 +319,9 @@ describe('ApiAuthorizationScreen', () => {
describe('decline action', () => {
test('navigates to /organizations after declining', async () => {
const user = userEvent.setup()
const declineHandler = vi.fn(() => HttpResponse.json({ id: 'test-auth-id' }))
const declineHandler = vi.fn(() =>
HttpResponse.json<DeclineAuthorizationResponse>({ id: 'test-auth-id' })
)
mockBothEndpoints()
addAPIMock({
method: 'delete',
+37
View File
@@ -1,5 +1,6 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, getByText, render as originalRender, screen } from '@testing-library/react'
import { platformComponents as components } from 'api-types'
import type React from 'react'
import { useState } from 'react'
import { TooltipProvider } from 'ui'
@@ -8,6 +9,8 @@ import { CommandProvider } from 'ui-patterns/CommandMenu'
import { ProjectInfoInfinite } from '@/data/projects/projects-infinite-query'
import type { Organization } from '@/types'
type OrganizationResponse = components['schemas']['OrganizationResponse']
interface SelectorOptions {
container?: HTMLElement
}
@@ -70,6 +73,40 @@ export const createMockOrganization = (details: Partial<Organization>): Organiza
return Object.assign(base, details)
}
/**
* Returns the raw OpenAPI `OrganizationResponse` shape — without the
* frontend-derived fields (`managed_by`, `partner_id`) that
* `castOrganizationResponseToOrganization` attaches in the query layer.
*
* Use this for MSW mocks at the network boundary (e.g. `/platform/organizations`).
* Use `createMockOrganization` for in-app fixtures that need the cast shape.
*/
export const createMockOrganizationResponse = (
details: Partial<OrganizationResponse> = {}
): OrganizationResponse => {
const base: OrganizationResponse = {
id: 1,
name: 'Organization 1',
slug: 'abcdefghijklmnopqrst',
plan: { id: 'free', name: 'Free' },
is_owner: true,
billing_email: 'billing@example.com',
billing_partner: null,
integration_source: null,
usage_billing_enabled: false,
stripe_customer_id: 'stripe-1',
subscription_id: 'subscription-1',
organization_requires_mfa: false,
opt_in_tags: [],
restriction_status: null,
restriction_data: null,
organization_missing_address: false,
organization_missing_tax_id: false,
}
return Object.assign(base, details)
}
export const createMockProject = (details: Partial<ProjectInfoInfinite>): ProjectInfoInfinite => {
const base: ProjectInfoInfinite = {
id: 1,
+2 -3
View File
@@ -25,9 +25,8 @@ const CustomWrapper = ({
queryClient ??
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
queries: { retry: false },
mutations: { retry: false },
},
})
+19 -4
View File
@@ -1,4 +1,4 @@
import { http, HttpResponse, HttpResponseResolver } from 'msw'
import { DefaultBodyType, http, HttpResponse, HttpResponseResolver, PathParams } from 'msw'
import { setupServer } from 'msw/node'
import type { paths } from '../../data/api'
@@ -39,6 +39,20 @@ type SuccessResponse<P extends Endpoints, M extends Methods> = RemapPaths[P][M]
? R201
: never
// Studio's standard error envelope — what `handleError` and `ResponseError` consume.
// OpenAPI doesn't document error bodies (4xx/5xx are `content?: never`), so we
// hardcode this convention to keep error-state tests strongly typed.
export type APIErrorBody = { message: string }
// Resolver constrained to the OpenAPI success body (or the error envelope above).
// Catches drift between mocks and the API contract. `Extract<..., DefaultBodyType>`
// filters to the JSON-body subset to satisfy `HttpResponseResolver`'s body constraint.
type TypedResolver<P extends Endpoints, M extends Methods> = HttpResponseResolver<
PathParams,
DefaultBodyType,
Extract<SuccessResponse<P, M>, DefaultBodyType> | APIErrorBody
>
const isResponseResolver = (val: unknown): val is HttpResponseResolver => typeof val === `function`
export const addAPIMock = <P extends Endpoints | `${Endpoints}?${string}`, M extends Methods>({
@@ -46,12 +60,13 @@ export const addAPIMock = <P extends Endpoints | `${Endpoints}?${string}`, M ext
path,
response,
}: SuccessResponse<TrimQueryParams<P>, M> extends never
? // Don't require a mocked response when the API doesn't return one
{ method: M; path: P; response?: never }
? // Endpoints with no documented JSON response body — resolver is optional, used
// when the test needs to assert on the request or override the status.
{ method: M; path: P; response?: HttpResponseResolver }
: {
method: M
path: P
response: SuccessResponse<TrimQueryParams<P>, M> | HttpResponseResolver
response: SuccessResponse<TrimQueryParams<P>, M> | TypedResolver<TrimQueryParams<P>, M>
}) => {
const fullPath = `${API_URL}${path}`
console.log('[MSW] Adding mock:', method.toUpperCase(), fullPath)
@@ -1,5 +1,6 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { platformComponents as components } from 'api-types'
import { LOCAL_STORAGE_KEYS } from 'common'
import { http, HttpResponse } from 'msw'
import { beforeEach, describe, expect, test, vi } from 'vitest'
@@ -12,10 +13,11 @@ import type {
import { CREATE_AWS_MANAGED_ORG_FORM_ID } from '@/components/interfaces/Organization/CloudMarketplace/NewAwsMarketplaceOrgForm'
import { API_URL } from '@/lib/constants'
import type { ProfileContextType } from '@/lib/profile'
import { createMockOrganization } from '@/tests/helpers'
import { createMockOrganizationResponse } from '@/tests/helpers'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock, mswServer } from '@/tests/lib/msw'
import type { Organization } from '@/types'
type OrganizationResponse = components['schemas']['OrganizationResponse']
const DEFAULT_PROFILE_CONTEXT: ProfileContextType = {
profile: {
@@ -38,13 +40,13 @@ const DEFAULT_PROFILE_CONTEXT: ProfileContextType = {
isSuccess: true,
}
const LINKABLE_ORG = createMockOrganization({
const LINKABLE_ORG = createMockOrganizationResponse({
id: 1,
name: 'Acme Production',
slug: 'acme-production',
plan: { id: 'pro', name: 'Pro' },
})
const UNAVAILABLE_ORG = createMockOrganization({
const UNAVAILABLE_ORG = createMockOrganizationResponse({
id: 2,
name: 'Legacy Billing',
slug: 'legacy-billing',
@@ -63,7 +65,7 @@ function createOnboardingInfo({
organizations = [LINKABLE_ORG, UNAVAILABLE_ORG],
eligibleSlugs = [LINKABLE_ORG.slug],
}: {
organizations?: Organization[]
organizations?: OrganizationResponse[]
eligibleSlugs?: string[]
} = {}): CloudMarketplaceOnboardingInfo {
return {
@@ -86,24 +88,24 @@ function mockAwsEndpoints({
eligibility = ELIGIBLE_CONTRACT,
onboardingInfo = createOnboardingInfo({ organizations }),
}: {
organizations?: Organization[]
organizations?: OrganizationResponse[]
eligibility?: CloudMarketplaceContractLinkingEligibility
onboardingInfo?: CloudMarketplaceOnboardingInfo
} = {}) {
addAPIMock({
method: 'get',
path: '/platform/organizations',
response: () => HttpResponse.json(organizations),
response: () => HttpResponse.json<OrganizationResponse[]>(organizations),
})
addAPIMock({
method: 'get',
path: '/platform/cloud-marketplace/buyers/:buyer_id/contract-linking-eligibility',
response: () => HttpResponse.json(eligibility),
response: () => HttpResponse.json<CloudMarketplaceContractLinkingEligibility>(eligibility),
})
addAPIMock({
method: 'get',
path: '/platform/cloud-marketplace/buyers/:buyer_id/onboarding-info',
response: () => HttpResponse.json(onboardingInfo),
response: () => HttpResponse.json<CloudMarketplaceOnboardingInfo>(onboardingInfo),
})
}
@@ -180,7 +182,7 @@ describe('AwsMarketplaceOnboardingScreen', () => {
http.post(`${API_URL}/platform/organizations/cloud-marketplace`, async ({ request }) => {
createRequest = await request.json()
return HttpResponse.json(
createMockOrganization({
createMockOrganizationResponse({
id: 3,
name: 'Mock Marketplace Org',
slug: 'mock-marketplace-org',
@@ -263,11 +265,11 @@ describe('AwsMarketplaceOnboardingScreen', () => {
test('promotes the last visited organization into the first visible organizations', async () => {
const organizations = [
createMockOrganization({ id: 1, name: 'Alpha Team', slug: 'alpha-team' }),
createMockOrganization({ id: 2, name: 'Beta Team', slug: 'beta-team' }),
createMockOrganization({ id: 3, name: 'Delta Team', slug: 'delta-team' }),
createMockOrganization({ id: 4, name: 'Gamma Team', slug: 'gamma-team' }),
createMockOrganization({ id: 5, name: 'Zeta Team', slug: 'zeta-team' }),
createMockOrganizationResponse({ id: 1, name: 'Alpha Team', slug: 'alpha-team' }),
createMockOrganizationResponse({ id: 2, name: 'Beta Team', slug: 'beta-team' }),
createMockOrganizationResponse({ id: 3, name: 'Delta Team', slug: 'delta-team' }),
createMockOrganizationResponse({ id: 4, name: 'Gamma Team', slug: 'gamma-team' }),
createMockOrganizationResponse({ id: 5, name: 'Zeta Team', slug: 'zeta-team' }),
]
window.localStorage.setItem(