Files
MealQuest/web/src/settings/CalendarSettingsPage.test.tsx
T
2026-10-08 03:13:21 -04:00

385 lines
18 KiB
TypeScript

import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { Route, Routes, useLocation } from 'react-router'
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { GOOGLE_NOT_CONNECTED } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { CalendarSettingsPage } from './CalendarSettingsPage.tsx'
const GOOGLE = '/api/v1/integrations/google'
const CALENDARS = '/api/v1/integrations/google/calendars'
const CALENDAR = '/api/v1/integrations/google/calendar'
// CONNECTED is a connection pushing to the household's Dinners calendar.
const CONNECTED = {
status: 'connected',
calendar_id: 'dinners@group.calendar.google.com',
calendar_name: 'Dinners',
connected_at: '2026-10-07T15:00:00Z',
last_sync_error: null,
}
// RECONNECT is CONNECTED once Google has refused its grant.
const RECONNECT = {
...CONNECTED,
status: 'reconnect_required',
last_sync_error: 'Google access was revoked or expired — reconnect',
}
// CALENDAR_LIST is the account's writable calendars, in Google's order.
const CALENDAR_LIST = [
{ id: 'sam@example.com', summary: 'sam@example.com', primary: true, accessRole: 'owner' },
{ id: 'dinners@group.calendar.google.com', summary: 'Dinners', primary: false, accessRole: 'owner' },
{ id: 'family@group.calendar.google.com', summary: 'Family', primary: false, accessRole: 'writer' },
]
type Responder = (body: unknown) => Response | Promise<Response>
// stubAPI answers "METHOD path" from responders, each called with the
// request's decoded JSON body. Any other request fails the test.
function stubAPI(responders: Record<string, Responder>): Mock<typeof fetch> {
const all: Record<string, Responder> = { 'GET /api/v1/me': () => jsonResponse(200, ME), ...responders }
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined
return Promise.resolve(respond(body))
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
// sent lists the requests other than GETs, as "METHOD path" and body.
function sent(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls
.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')
.map(([input, init]) => ({
request: `${init?.method} ${String(input)}`,
body: typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined,
}))
}
// reads counts the GETs of path.
function reads(fetchMock: Mock<typeof fetch>, path: string) {
return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === path).length
}
// deferred is a response the test sends when it chooses.
function deferred() {
let send!: (response: Response) => void
const response = new Promise<Response>((resolve) => {
send = resolve
})
return { response, send }
}
afterEach(() => {
vi.unstubAllGlobals()
})
function CurrentPath() {
return <p data-testid="path">{useLocation().pathname}</p>
}
// renderPage loads /me (for the CSRF token every write needs) and renders
// the page at /settings/calendar in a data router.
async function renderPage() {
const api = createApiClient(fakeLocation('/settings/calendar'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const queryClient = createQueryClient()
render(
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>
<InDataRouter path="/settings/calendar">
<Routes>
<Route path="/settings/calendar" element={<CalendarSettingsPage />} />
<Route path="*" element={<CurrentPath />} />
</Routes>
</InDataRouter>
</QueryClientProvider>
</ApiProvider>,
)
return queryClient
}
describe('CalendarSettingsPage', () => {
it('shows the screen title and goes back to Settings', async () => {
stubAPI({ [`GET ${GOOGLE}`]: () => jsonResponse(200, GOOGLE_NOT_CONNECTED) })
await renderPage()
expect(screen.getByRole('heading', { level: 1, name: 'Google Calendar' })).toBeInTheDocument()
expect(screen.getByText('Settings')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
})
it('shows "Connect Google Calendar" when not connected, opening the connect endpoint as a page', async () => {
const fetchMock = stubAPI({ [`GET ${GOOGLE}`]: () => jsonResponse(200, GOOGLE_NOT_CONNECTED) })
await renderPage()
const connect = await screen.findByRole('link', { name: 'Connect Google Calendar' })
expect(connect).toHaveAttribute('href', '/api/v1/integrations/google/connect')
expect(declaredStyle(connect).background).toBe('var(--color-accent)')
expect(screen.getByRole('region', { name: 'Not connected' })).toBeInTheDocument()
// Nothing to pick yet, nor to disconnect, and Google is not asked.
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Disconnect' })).not.toBeInTheDocument()
expect(reads(fetchMock, CALENDARS)).toBe(0)
})
it('reports a status that could not load, and reads it again on Retry', async () => {
let status = () => errorResponse(500, 'internal')
stubAPI({ [`GET ${GOOGLE}`]: () => status() })
await renderPage()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent('Google Calendar status could not load: internal message')
status = () => jsonResponse(200, GOOGLE_NOT_CONNECTED)
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(await screen.findByRole('link', { name: 'Connect Google Calendar' })).toBeInTheDocument()
})
it('refuses a status it does not know', async () => {
stubAPI({ [`GET ${GOOGLE}`]: () => jsonResponse(200, { ...GOOGLE_NOT_CONNECTED, status: 'paused' }) })
await renderPage()
expect(await screen.findByRole('alert')).toHaveTextContent('response is not a Google Calendar connection')
})
it('shows "Connected · {calendar}" in teal-800 and the writable calendars to pick from', async () => {
stubAPI({
[`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED),
[`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST),
})
await renderPage()
const connected = await screen.findByText('Connected · Dinners')
expect(declaredStyle(connected).color).toBe('var(--color-accent-2-800)')
expect(connected.querySelector('svg')).not.toBeNull()
const picker = await screen.findByRole('combobox', { name: 'Calendar' })
expect(picker).toHaveValue('dinners@group.calendar.google.com')
expect(within(picker).getAllByRole('option').map((option) => option.textContent)).toEqual([
'sam@example.com (primary)',
'Dinners',
'Family',
])
expect(screen.getByRole('button', { name: 'Disconnect' })).toBeInTheDocument()
expect(screen.queryByRole('link', { name: /Connect|Reconnect/ })).not.toBeInTheDocument()
})
it('chooses another calendar at once, showing it saving, and reads the plans again', async () => {
const answer = deferred()
const fetchMock = stubAPI({
[`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED),
[`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST),
[`PUT ${CALENDAR}`]: () => answer.response,
})
const queryClient = await renderPage()
queryClient.setQueryData(['plans', '2026-10-05'], null)
const picker = await screen.findByRole('combobox', { name: 'Calendar' })
fireEvent.change(picker, { target: { value: 'family@group.calendar.google.com' } })
await waitFor(() =>
expect(sent(fetchMock)).toEqual([{ request: `PUT ${CALENDAR}`, body: { calendar_id: 'family@group.calendar.google.com' } }]),
)
expect(await screen.findByText('Saving…')).toBeInTheDocument()
expect(picker).toBeDisabled()
expect(picker).toHaveValue('family@group.calendar.google.com')
answer.send(jsonResponse(200, { calendar_id: 'family@group.calendar.google.com', calendar_name: 'Family' }))
expect(await screen.findByText('Connected · Family')).toBeInTheDocument()
expect(picker).toBeEnabled()
expect(picker).toHaveValue('family@group.calendar.google.com')
expect(screen.queryByText('Saving…')).not.toBeInTheDocument()
// Choosing another calendar makes every pushed plan changed.
expect(queryClient.getQueryState(['plans', '2026-10-05'])?.isInvalidated).toBe(true)
})
it('says a choice that was not saved, keeping the calendar chosen before', async () => {
stubAPI({
[`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED),
[`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST),
[`PUT ${CALENDAR}`]: () => errorResponse(422, 'unknown_calendar'),
})
await renderPage()
const picker = await screen.findByRole('combobox', { name: 'Calendar' })
fireEvent.change(picker, { target: { value: 'family@group.calendar.google.com' } })
expect(await screen.findByRole('alert')).toHaveTextContent('Not saved: unknown_calendar message')
expect(picker).toHaveValue('dinners@group.calendar.google.com')
expect(screen.getByText('Connected · Dinners')).toBeInTheDocument()
})
it('asks to choose a calendar when connected with none chosen', async () => {
stubAPI({
[`GET ${GOOGLE}`]: () => jsonResponse(200, { ...CONNECTED, calendar_id: null, calendar_name: null }),
[`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST),
})
await renderPage()
expect(await screen.findByText('Connected')).toBeInTheDocument()
expect(await screen.findByText('Choose the calendar to push dinners to.')).toBeInTheDocument()
const picker = screen.getByRole('combobox', { name: 'Calendar' })
expect(picker).toHaveValue('')
expect(within(picker).getByRole('option', { name: 'Choose a calendar' })).toBeDisabled()
})
it('says the chosen calendar can no longer be pushed to once it drops out of the list', async () => {
stubAPI({
[`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED),
[`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST.filter((calendar) => calendar.summary !== 'Dinners')),
})
await renderPage()
expect(
await screen.findByText('Dinners was deleted or MealQuest can no longer add dinners to it. Choose another calendar.'),
).toBeInTheDocument()
expect(screen.getByRole('combobox', { name: 'Calendar' })).toHaveValue('')
})
it('says when the account has no calendar MealQuest can add dinners to', async () => {
stubAPI({
[`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED),
[`GET ${CALENDARS}`]: () => jsonResponse(200, []),
})
await renderPage()
expect(await screen.findByText('This Google account has no calendar MealQuest can add dinners to.')).toBeInTheDocument()
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
})
it('reports calendars that could not load, and lists them again on Retry', async () => {
let list = () => errorResponse(502, 'google_failed')
stubAPI({
[`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED),
[`GET ${CALENDARS}`]: () => list(),
})
await renderPage()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent('Your calendars could not load: google_failed message')
list = () => jsonResponse(200, CALENDAR_LIST)
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(await screen.findByRole('combobox', { name: 'Calendar' })).toHaveValue('dinners@group.calendar.google.com')
})
it('turns to the reconnect banner when Google refuses the grant while listing calendars', async () => {
let status: unknown = CONNECTED
stubAPI({
[`GET ${GOOGLE}`]: () => jsonResponse(200, status),
[`GET ${CALENDARS}`]: () => {
status = RECONNECT
return errorResponse(409, 'google_reconnect_required')
},
})
await renderPage()
const banner = await screen.findByRole('region', { name: 'Reconnect Google Calendar' })
// Why and what to do are announced as the screen turns to the banner.
const alert = screen.getByRole('alert')
expect(banner).toContainElement(alert)
expect(alert).toHaveTextContent(
'Google access was revoked or expired — reconnect' + 'Nothing can be pushed to Calendar until you connect again.',
)
expect(within(alert).queryByRole('link')).not.toBeInTheDocument()
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
expect(screen.queryByText('Connected · Dinners')).not.toBeInTheDocument()
})
it('shows a grape banner with Reconnect when reconnect is required, and offers nothing that needs Google', async () => {
const fetchMock = stubAPI({ [`GET ${GOOGLE}`]: () => jsonResponse(200, RECONNECT) })
await renderPage()
const banner = await screen.findByRole('region', { name: 'Reconnect Google Calendar' })
expect(declaredStyle(banner).background).toBe('var(--color-accent-100)')
expect(declaredStyle(banner).color).toBe('var(--color-accent-800)')
expect(banner).toHaveTextContent('Google access was revoked or expired — reconnect')
expect(banner).toHaveTextContent('Nothing can be pushed to Calendar until you connect again.')
expect(within(banner).getByRole('link', { name: 'Reconnect' })).toHaveAttribute(
'href',
'/api/v1/integrations/google/connect',
)
// The chosen calendar is kept, but not picked from, nor called connected.
expect(screen.getByText('Dinners go to Dinners once Google Calendar is reconnected.')).toBeInTheDocument()
expect(screen.queryByText(/^Connected/)).not.toBeInTheDocument()
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
expect(reads(fetchMock, CALENDARS)).toBe(0)
expect(screen.getByRole('button', { name: 'Disconnect' })).toBeInTheDocument()
})
it('disconnects only once confirmed, then shows Connect Google Calendar', async () => {
let status: unknown = CONNECTED
const answer = deferred()
const fetchMock = stubAPI({
[`GET ${GOOGLE}`]: () => jsonResponse(200, status),
[`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST),
[`DELETE ${GOOGLE}`]: () => answer.response,
})
const queryClient = await renderPage()
queryClient.setQueryData(['plans', '2026-10-05'], null)
fireEvent.click(await screen.findByRole('button', { name: 'Disconnect' }))
let dialog = await screen.findByRole('dialog', { name: 'Disconnect Google Calendar?' })
expect(dialog).toHaveTextContent('Dinners already pushed to Dinners stay on the calendar.')
fireEvent.click(within(dialog).getByRole('button', { name: 'Keep connected' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(sent(fetchMock)).toEqual([])
fireEvent.click(screen.getByRole('button', { name: 'Disconnect' }))
dialog = await screen.findByRole('dialog', { name: 'Disconnect Google Calendar?' })
fireEvent.click(within(dialog).getByRole('button', { name: 'Disconnect' }))
await waitFor(() => expect(sent(fetchMock)).toEqual([{ request: `DELETE ${GOOGLE}`, body: undefined }]))
// While it disconnects the sheet stays, and cannot be dismissed.
expect(await within(dialog).findByRole('button', { name: 'Disconnecting…' })).toBeDisabled()
expect(within(dialog).getByRole('button', { name: 'Keep connected' })).toBeDisabled()
status = GOOGLE_NOT_CONNECTED
answer.send(new Response(null, { status: 204 }))
expect(await screen.findByRole('link', { name: 'Connect Google Calendar' })).toBeInTheDocument()
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
// Disconnecting forgets every pushed event, so pushed plans are changed.
expect(queryClient.getQueryState(['plans', '2026-10-05'])?.isInvalidated).toBe(true)
})
it('says a disconnect Google refused, keeping the connection', async () => {
stubAPI({
[`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED),
[`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST),
[`DELETE ${GOOGLE}`]: () => errorResponse(502, 'revoke_failed'),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Disconnect' }))
const dialog = await screen.findByRole('dialog', { name: 'Disconnect Google Calendar?' })
fireEvent.click(within(dialog).getByRole('button', { name: 'Disconnect' }))
expect(await within(dialog).findByRole('alert')).toHaveTextContent('Not disconnected: revoke_failed message')
fireEvent.click(within(dialog).getByRole('button', { name: 'Keep connected' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(screen.getByText('Connected · Dinners')).toBeInTheDocument()
})
it('disconnects a connection that needs reconnecting', async () => {
let status: unknown = RECONNECT
const fetchMock = stubAPI({
[`GET ${GOOGLE}`]: () => jsonResponse(200, status),
[`DELETE ${GOOGLE}`]: () => {
status = GOOGLE_NOT_CONNECTED
return new Response(null, { status: 204 })
},
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Disconnect' }))
const dialog = await screen.findByRole('dialog', { name: 'Disconnect Google Calendar?' })
fireEvent.click(within(dialog).getByRole('button', { name: 'Disconnect' }))
expect(await screen.findByRole('link', { name: 'Connect Google Calendar' })).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([{ request: `DELETE ${GOOGLE}`, body: undefined }])
})
})