Feature 16: Google Calendar Sync (#130)
CI / ci (push) Successful in 4m51s

Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #130.
This commit is contained in:
2026-10-08 03:13:21 -04:00
committed by approver_agent
parent 5ac5982c1f
commit 7003ff92ec
65 changed files with 8092 additions and 222 deletions
+359 -12
View File
@@ -15,6 +15,7 @@ const THIS_WEEK = '/api/v1/plans/2026-10-05'
const NEXT_WEEK = '/api/v1/plans/2026-10-12'
const PREVIOUS_WEEK = '/api/v1/plans/2026-09-28'
const DRAFT = `${THIS_WEEK}/draft`
const PUSH = `${THIS_WEEK}/push-calendar`
const JOB = '/api/v1/jobs/j1'
const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11']
@@ -50,6 +51,21 @@ function planBody(status: string, names = NAMES) {
}
}
// syncedNow is the plan of the week of Oct 5 as a push answers it: synced
// at the tests' noon.
function syncedNow() {
return { ...planBody('synced'), calendar_synced_at: '2026-10-07T16:00:00Z' }
}
// 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 }
}
// draftJob is job j1 drafting the week of Oct 5 into plan planId: a
// redraft keeps the plan's id.
function draftJob(status: string, error: string | null = null, planId = 'plan-1') {
@@ -225,17 +241,312 @@ describe('PlanPage', () => {
expect(screen.queryByText('Draft')).not.toBeInTheDocument()
})
it('offers Push to Calendar unavailable, with a tooltip saying why', async () => {
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) })
it('pushes the plan to Calendar, showing progress, then the plan as synced', async () => {
const answer = deferred()
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => answer.response,
})
await renderPage()
const push = await screen.findByRole('button', { name: 'Looks good — push to Calendar' })
expect(push).toHaveAttribute('aria-disabled', 'true')
expect(push).toHaveAccessibleDescription('Push to Calendar arrives once Google Calendar can be connected.')
expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('Push to Calendar arrives once Google Calendar can be connected.')
expect(await screen.findByText('Not pushed to Google Calendar yet')).toBeInTheDocument()
const push = screen.getByRole('button', { name: 'Looks good — push to Calendar' })
expect(declaredStyle(push).minHeight).toBe('50px')
fireEvent.click(push)
expect(posts(fetchMock)).toEqual([])
await waitFor(() => expect(posts(fetchMock)).toEqual([`${PUSH} undefined`]))
const call = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST')!
expect(new Headers(call[1]?.headers).get('X-MealQuest-CSRF')).toBe(ME.csrfToken)
// In progress: the button says so and is disabled, as is Rebuild week,
// and no day can be swapped.
const pushing = await screen.findByRole('button', { name: 'Pushing to Calendar…' })
expect(pushing).toBeDisabled()
expect(pushing).toHaveAttribute('aria-busy', 'true')
expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeDisabled()
fireEvent.click(within(tile('MON')).getByRole('button'))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
answer.send(jsonResponse(200, syncedNow()))
const status = await screen.findByText('Synced to Google Calendar just now')
expect(declaredStyle(status).color).toBe('var(--color-accent-2-800)')
expect(status.querySelector('svg')).not.toBeNull()
expect(screen.getByText('Synced')).toBeInTheDocument()
expect(screen.queryByText('Draft')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeEnabled()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
// The plan shown is the one the push answered with: no second read.
expect(planReads(fetchMock)).toBe(1)
})
it('says a synced plan is on Google Calendar, and how long ago it was pushed', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('synced')) })
await renderPage()
const status = await screen.findByText('Synced to Google Calendar 2 min ago')
expect(declaredStyle(status).color).toBe('var(--color-accent-2-800)')
expect(status.querySelector('svg')).not.toBeNull()
})
it('says changes to a pushed plan are not pushed, and pushes them', async () => {
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('changed')),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow()),
})
await renderPage()
expect(await screen.findByText('Changes not pushed')).toBeInTheDocument()
const status = screen.getByText('Changes not pushed to Google Calendar')
expect(declaredStyle(status).color).toBe('var(--color-neutral-700)')
fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' }))
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
expect(screen.queryByText('Changes not pushed')).not.toBeInTheDocument()
expect(posts(fetchMock)).toEqual([`${PUSH} undefined`])
})
it.each([
['google_reconnect_required', 'Google access was revoked or expired.', 'Reconnect Google Calendar'],
['no_calendar', 'No calendar is chosen for dinners yet.', 'Choose a calendar in Settings'],
['not_connected', 'Google Calendar is not connected.', 'Connect Google Calendar'],
['calendar_unavailable', 'The chosen calendar was deleted or MealQuest can no longer add dinners to it.', 'Choose another calendar'],
])('sends a %s push failure to the Google Calendar settings', async (code, reason, fix) => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => errorResponse(409, code),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent(`Couldn't push to Calendar. ${reason} ${fix}`)
expect(within(alert).getByRole('link', { name: fix })).toHaveAttribute('href', '/settings/calendar')
expect(within(alert).queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument()
// The plan is as it was: not synced.
expect(screen.getByText('Draft')).toBeInTheDocument()
expect(screen.getByText('Not pushed to Google Calendar yet')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeEnabled()
})
it('marks the Google Calendar connection to be read again when Google refused the grant', async () => {
const connection = { status: 'connected', calendar_id: 'c1', calendar_name: 'Dinners', connected_at: null, last_sync_error: null }
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
const queryClient = createQueryClient()
queryClient.setQueryData(['integrations', 'google'], connection)
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => errorResponse(409, 'google_reconnect_required'),
})
await api.loadMe()
render(
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/plan']}>
<PlanPage />
</MemoryRouter>
</QueryClientProvider>
</ApiProvider>,
)
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
expect(await screen.findByRole('link', { name: 'Reconnect Google Calendar' })).toBeInTheDocument()
// The settings screen reads it again as it opens: it is now reconnect
// required.
expect(queryClient.getQueryState(['integrations', 'google'])?.isInvalidated).toBe(true)
expect(posts(fetchMock)).toEqual([`${PUSH} undefined`])
})
it('says why any other push failed, and pushes again on Retry', async () => {
let push = () => errorResponse(502, 'google_failed')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => push(),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't push to Calendar. google_failed message")
expect(within(alert).queryByRole('link')).not.toBeInTheDocument()
push = () => jsonResponse(200, syncedNow())
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`])
})
it('reads the plan again when it changed while being pushed', async () => {
let plan = planBody('draft')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan),
[`POST ${PUSH}`]: () => errorResponse(409, 'plan_changed'),
})
await renderPage()
await screen.findByText('Beef tacos')
plan = planBody('draft', ['Lasagna', ...NAMES.slice(1)])
fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
"Couldn't push to Calendar. The plan changed while it was being pushed; push it again.",
)
expect(await screen.findByText('Lasagna')).toBeInTheDocument()
expect(planReads(fetchMock)).toBe(2)
})
it('pushes nothing again until the changed plan is read and shown', async () => {
let read: () => Response | Promise<Response> = () => jsonResponse(200, planBody('draft'))
let push = () => errorResponse(409, 'plan_changed')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => read(),
[`POST ${PUSH}`]: () => push(),
})
await renderPage()
await screen.findByText('Beef tacos')
const reread = deferred()
read = () => reread.response
const pushButton = screen.getByRole('button', { name: 'Looks good — push to Calendar' })
fireEvent.click(pushButton)
await waitFor(() => expect(planReads(fetchMock)).toBe(2))
// While the changed plan is read, the push stays busy: neither Push
// nor Retry sends it again.
expect(screen.getByRole('button', { name: 'Pushing to Calendar…' })).toBeDisabled()
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Pushing to Calendar…' }))
expect(screen.getByText('Beef tacos')).toBeInTheDocument()
expect(posts(fetchMock)).toEqual([`${PUSH} undefined`])
reread.send(jsonResponse(200, planBody('draft', ['Lasagna', ...NAMES.slice(1)])))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't push to Calendar. The plan changed while it was being pushed; push it again.")
expect(screen.getByText('Lasagna')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeEnabled()
push = () => jsonResponse(200, syncedNow())
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`])
})
it('pushes nothing again until the changed plan is shown, even after leaving the week and coming back', async () => {
let read: () => Response | Promise<Response> = () => jsonResponse(200, planBody('draft'))
let push = () => errorResponse(409, 'plan_changed')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => read(),
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
[`POST ${PUSH}`]: () => push(),
})
await renderPage()
await screen.findByText('Beef tacos')
const reread = deferred()
read = () => reread.response
fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' }))
await waitFor(() => expect(planReads(fetchMock)).toBe(2))
// Leaving the week and coming back while the changed plan is read
// remounts the board: the push is still busy, and sends nothing again.
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
await screen.findByText('Beef tacos')
expect(screen.getByRole('button', { name: 'Pushing to Calendar…' })).toBeDisabled()
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Pushing to Calendar…' }))
expect(posts(fetchMock)).toEqual([`${PUSH} undefined`])
await act(async () => reread.send(jsonResponse(200, planBody('draft', ['Lasagna', ...NAMES.slice(1)]))))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't push to Calendar. The plan changed while it was being pushed; push it again.")
expect(screen.getByText('Lasagna')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeEnabled()
push = () => jsonResponse(200, syncedNow())
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`])
})
it("pushes nothing again while the changed plan's read is answered from the cache", async () => {
let read = () => jsonResponse(200, planBody('draft'))
let push = () => errorResponse(409, 'plan_changed')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => read(),
[`POST ${PUSH}`]: () => push(),
})
await renderPage()
await screen.findByText('Beef tacos')
// The server can't be reached: the service worker answers the read of
// the changed plan with the plan it cached before.
read = () => {
const response = jsonResponse(200, planBody('draft'))
response.headers.set(cacheFallbackHeader, '1')
return response
}
fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' }))
const cachedCopy = "The plan changed while it was being pushed. The plan shown came from this device's cache, not the server; read it again before pushing."
const failed = await screen.findByRole('alert')
expect(planReads(fetchMock)).toBe(2)
expect(screen.getByText('Beef tacos')).toBeInTheDocument()
const pushButton = screen.getByRole('button', { name: 'Looks good — push to Calendar' })
expect(pushButton).toBeDisabled()
fireEvent.click(pushButton)
expect(failed).toHaveTextContent(cachedCopy)
// Retry reads the plan again rather than pushing: still the cache, so
// nothing is pushed.
fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(planReads(fetchMock)).toBe(3))
expect(await screen.findByRole('alert')).toHaveTextContent(cachedCopy)
expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeDisabled()
expect(posts(fetchMock)).toEqual([`${PUSH} undefined`])
// The server is back: Retry shows the changed plan, and only then may it
// be pushed.
read = () => jsonResponse(200, planBody('draft', ['Lasagna', ...NAMES.slice(1)]))
fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('Lasagna')).toBeInTheDocument()
const alert = screen.getByRole('alert')
expect(alert).toHaveTextContent("Couldn't push to Calendar. The plan changed while it was being pushed; push it again.")
expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeEnabled()
expect(posts(fetchMock)).toEqual([`${PUSH} undefined`])
push = () => jsonResponse(200, syncedNow())
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`])
})
it('shows the changed plan read while the week was left, and pushes it', async () => {
let read: () => Response | Promise<Response> = () => jsonResponse(200, planBody('draft'))
let push = () => errorResponse(409, 'plan_changed')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => read(),
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
[`POST ${PUSH}`]: () => push(),
})
await renderPage()
await screen.findByText('Beef tacos')
const reread = deferred()
read = () => reread.response
fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' }))
await waitFor(() => expect(planReads(fetchMock)).toBe(2))
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
await act(async () => reread.send(jsonResponse(200, planBody('draft', ['Lasagna', ...NAMES.slice(1)]))))
read = () => jsonResponse(200, planBody('draft', ['Lasagna', ...NAMES.slice(1)]))
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
// The board comes back on the changed plan: Push sends that one.
expect(await screen.findByText('Lasagna')).toBeInTheDocument()
expect(screen.queryByText('Beef tacos')).not.toBeInTheDocument()
expect(screen.getByRole('alert')).toHaveTextContent('The plan changed while it was being pushed; push it again.')
push = () => jsonResponse(200, syncedNow())
fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' }))
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`])
})
it('rebuilds a draft at once, showing skeleton tiles until the new plan is read', async () => {
@@ -715,20 +1026,19 @@ describe.each([
expect(screen.getByRole('button', { name: 'Next week' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'This week' }) !== null).toBe(layout === 'desktop')
// Actions: the shopping list and Push to Calendar unavailable, saying
// why; Rebuild week outlined and working.
// Actions: the shopping list unavailable, saying why; Rebuild week
// outlined and Push to Calendar primary, both working.
const shopping = screen.getByRole('button', { name: 'Shopping list' })
expect(shopping).toHaveAttribute('aria-disabled', 'true')
expect(shopping).toHaveAccessibleDescription('The shopping list arrives once the Supply run is built.')
const push = screen.getByRole('button', { name: 'Push to Calendar' })
expect(push).toHaveAttribute('aria-disabled', 'true')
expect(push).toHaveAccessibleDescription('Push to Calendar arrives once Google Calendar can be connected.')
expect(push).toBeEnabled()
expect(push).not.toHaveAttribute('aria-disabled')
expect(declaredStyle(push).background).toBe('var(--color-accent)')
const rebuild = screen.getByRole('button', { name: 'Rebuild week' })
expect(rebuild).toBeEnabled()
expect(declaredStyle(rebuild).borderColor).toBe('var(--color-neutral-300)')
fireEvent.click(shopping)
fireEvent.click(push)
expect(posts(fetchMock)).toEqual([])
// Mobile's own pieces are not shown.
expect(screen.queryByRole('button', { name: 'Looks good — push to Calendar' })).not.toBeInTheDocument()
@@ -795,6 +1105,43 @@ describe.each([
expect(await screen.findByText('Changes not pushed to Google Calendar')).toBeInTheDocument()
})
it('pushes the plan from the header, showing progress, then the plan as synced', async () => {
const answer = deferred()
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('changed')),
[`POST ${PUSH}`]: () => answer.response,
...lists,
})
await renderPage()
await screen.findByText('Beef tacos')
fireEvent.click(screen.getByRole('button', { name: 'Push to Calendar' }))
const pushing = await screen.findByRole('button', { name: 'Pushing…' })
expect(pushing).toBeDisabled()
expect(pushing).toHaveAttribute('aria-busy', 'true')
expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeDisabled()
answer.send(jsonResponse(200, syncedNow()))
const status = await screen.findByText('Synced to Google Calendar just now')
expect(declaredStyle(status).color).toBe('var(--color-accent-2-800)')
expect(screen.getByRole('button', { name: 'Push to Calendar' })).toBeEnabled()
})
it('links a google_reconnect_required push failure to the Google Calendar settings', async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('synced')),
[`POST ${PUSH}`]: () => errorResponse(409, 'google_reconnect_required'),
...lists,
})
await renderPage()
await screen.findByText('Beef tacos')
fireEvent.click(screen.getByRole('button', { name: 'Push to Calendar' }))
const alert = await screen.findByRole('alert')
expect(within(alert).getByRole('link', { name: 'Reconnect Google Calendar' })).toHaveAttribute('href', '/settings/calendar')
expect(screen.getByText('Synced to Google Calendar 2 min ago')).toBeInTheDocument()
})
if (layout === 'desktop') {
it('shows the Quest log and Regulars in the 290 px right panel', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists })