mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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 --> [](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:
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` |
|
||||
+69
@@ -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()
|
||||
})
|
||||
})
|
||||
-1
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -25,9 +25,8 @@ const CustomWrapper = ({
|
||||
queryClient ??
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user