import { describe, expect, it, beforeEach } from 'vitest'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { QueryClient } from '@tanstack/react-query'
import { server } from '@/test/msw/server'
import { TestProviders } from '@/test/render'
import { API_BASE_URL, mockUser } from '@/test/fixtures'
import { ApiEndpoints } from '@/config/api'
import { queryKeys } from './query-keys'
import VerifyEmailAlert from '@/app/(app)/dashboard/(components)/alerts/verify-email-alert'
import GenerateApiKey from '@/app/(app)/dashboard/(components)/api-keys/generate-api-key'
const url = (path: string) => `${API_BASE_URL}${path.split('?')[0]}`
/**
* These cover cache bugs that are invisible on inspection: the code looks
* correct at every individual call site, and only the relationship between
* sites is wrong. Both shipped to production.
*/
describe('cache invalidation', () => {
let queryClient: QueryClient
beforeEach(() => {
// Not makeTestQueryClient: it sets gcTime 0, which collects any cache
// entry the moment it has no observer, so priming a cache to assert it
// gets invalidated would never survive to be asserted on.
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
})
const renderWith = (ui: React.ReactElement) =>
render({ui})
// /auth/who-am-i used to be cached under two keys, ['whoAmI'] and
// ['currentUser'], so invalidating one left the other serving stale data.
// A user who verified their email still saw the "verify your email" banner,
// because the code that refreshed the user used the other key.
it('one invalidation refreshes every consumer of the current user', async () => {
let verified = false
server.use(
http.get(url(ApiEndpoints.auth.whoAmI()), () =>
HttpResponse.json({
data: {
...mockUser,
emailVerifiedAt: verified ? new Date().toISOString() : null,
},
})
)
)
const { container } = renderWith()
// Selected by href, not by label: the banner picks its CTA text at random
// from five variants, so a text matcher here would be flaky.
const banner = () => container.querySelector('a[href="/verify-email"]')
// Unverified: the banner is up.
await waitFor(() => expect(banner()).toBeInTheDocument())
// The user verifies elsewhere, and something invalidates the user query
// using the canonical key.
verified = true
await queryClient.invalidateQueries({ queryKey: queryKeys.currentUser })
// The banner must notice. Under the two-key split it never did.
await waitFor(() => expect(banner()).toBeNull())
})
// The generate handler invalidated ['apiKeys', 'stats'], a key no query uses.
// react-query matches by prefix, so it matched neither the key list
// (['apiKeys', 'active']) nor the dashboard stats (['stats']): generating a
// key refreshed nothing at all.
it('generating an API key refreshes both the key list and the stats', async () => {
server.use(
http.post(url(ApiEndpoints.auth.generateApiKey()), () =>
HttpResponse.json({ data: 'new-api-key' })
)
)
// Prime the two caches a new key must invalidate.
queryClient.setQueryData(queryKeys.apiKeys('active'), { data: [] })
queryClient.setQueryData(queryKeys.stats, { totalApiKeyCount: 0 })
renderWith()
// The trigger and the dialog's confirm button carry the same label, so the
// confirm has to be scoped to the dialog.
await userEvent.click(
screen.getByRole('button', { name: /Generate API Key/i })
)
const confirmDialog = await screen.findByRole('dialog', {
name: /Create new API Key/i,
})
await userEvent.click(
within(confirmDialog).getByRole('button', { name: /Generate API Key/i })
)
await waitFor(() => {
expect(
queryClient.getQueryState(queryKeys.apiKeys('active'))?.isInvalidated,
'the API key list must be invalidated'
).toBe(true)
expect(
queryClient.getQueryState(queryKeys.stats)?.isInvalidated,
'the dashboard stats must be invalidated'
).toBe(true)
})
})
})