55 *
66 * Copyright Oxide Computer Company
77 */
8+ import { QueryClientProvider , useQuery } from '@tanstack/react-query'
89import { http , HttpResponse } from 'msw'
910import { setupWorker } from 'msw/browser'
11+ import type { ReactNode } from 'react'
1012import { afterAll , afterEach , beforeAll , describe , expect , it } from 'vitest'
13+ import { render } from 'vitest-browser-react'
1114
1215import { project } from '@oxide/api-mocks'
1316
14- import { api , q } from '..'
17+ import { api , type Project , q , queryClient , type ResultsPage , useApiMutation } from '..'
1518import { resetDb } from '../../../mock-api/msw/db'
1619import { handlers } from '../../../mock-api/msw/handlers'
1720import { processServerError } from '../errors'
@@ -31,6 +34,7 @@ beforeAll(() => worker.start({ quiet: true, onUnhandledRequest: 'error' }))
3134afterEach ( ( ) => {
3235 resetDb ( )
3336 worker . resetHandlers ( )
37+ queryClient . clear ( )
3438} )
3539afterAll ( ( ) => worker . stop ( ) )
3640
@@ -54,13 +58,35 @@ function overrideOnce(
5458 )
5559}
5660
57- // useApiQuery and useApiMutation are almost entirely typed wrappers around React
58- // Query's useQuery and useMutation, so they're exercised end-to-end by the
59- // Playwright suite (every error toast goes through this path). The logic worth
60- // unit-testing directly is response parsing in the generated client
61- // (`handleResponse`) and the error transformation in `processServerError` (the
62- // latter is covered exhaustively in errors.spec.ts). These tests call the API
63- // methods directly — no React, no renderHook — since they return an ApiResult.
61+ // The API hooks are mostly typed wrappers around React Query and are exercised
62+ // end-to-end by the Playwright suite. Most tests here therefore call API methods
63+ // directly; the mutation invalidation test renders a component because the
64+ // mutation's loading state is the behavior under test.
65+
66+ const QueryClientWrapper = ( { children } : { children : ReactNode } ) => (
67+ < QueryClientProvider client = { queryClient } > { children } </ QueryClientProvider >
68+ )
69+
70+ function MutationInvalidationTest ( { onSuccess } : { onSuccess : ( ) => void } ) {
71+ const projects = useQuery ( q ( api . projectList , { } ) )
72+ const createProject = useApiMutation ( api . projectCreate , {
73+ invalidateEndpoints : [ 'projectList' ] ,
74+ onSuccess,
75+ } )
76+ const count = projects . data ?. items . length ?? 0
77+
78+ return (
79+ < button
80+ type = "button"
81+ disabled = { createProject . isPending }
82+ onClick = { ( ) =>
83+ createProject . mutate ( { body : { name : 'new-project' , description : '' } } )
84+ }
85+ >
86+ { createProject . isPending ? 'Creating' : 'Create' } project ({ count } projects)
87+ </ button >
88+ )
89+ }
6490
6591describe ( 'API response parsing' , ( ) => {
6692 it ( 'returns success data for a normal response' , async ( ) => {
@@ -126,3 +152,39 @@ it('apiq queryKey', () => {
126152 const queryOptions = q ( api . siloView , params )
127153 expect ( queryOptions . queryKey ) . toEqual ( [ 'siloView' , params ] )
128154} )
155+
156+ it ( 'stays pending until invalidated queries have refreshed' , async ( ) => {
157+ // capture the cached list length at onSuccess call time so we can assert
158+ // onSuccess ran after the invalidated query refetched
159+ let countAtSuccess : number | undefined
160+ const onSuccess = ( ) => {
161+ const projects = queryClient . getQueryData < ResultsPage < Project > > ( [ 'projectList' , { } ] )
162+ countAtSuccess = projects ?. items . length
163+ }
164+ const screen = await render ( < MutationInvalidationTest onSuccess = { onSuccess } /> , {
165+ wrapper : QueryClientWrapper ,
166+ } )
167+ const createButton = screen . getByRole ( 'button' )
168+ await expect . element ( createButton ) . toHaveAccessibleName ( 'Create project (3 projects)' )
169+
170+ const { promise : refetch , resolve : releaseRefetch } = Promise . withResolvers < void > ( )
171+ worker . use (
172+ http . get (
173+ 'http://testhost/v1/projects' ,
174+ async ( ) => {
175+ await refetch
176+ return HttpResponse . json ( { items : [ project , project , project , project ] } )
177+ } ,
178+ { once : true }
179+ )
180+ )
181+
182+ await createButton . click ( )
183+ await expect . element ( createButton ) . toBeDisabled ( )
184+ await expect . element ( createButton ) . toHaveAccessibleName ( 'Creating project (3 projects)' )
185+
186+ releaseRefetch ( )
187+ await expect . element ( createButton ) . toBeEnabled ( )
188+ await expect . element ( createButton ) . toHaveAccessibleName ( 'Create project (4 projects)' )
189+ expect ( countAtSuccess ) . toEqual ( 4 )
190+ } )
0 commit comments