Open saved pages offline, cache recipe reads, flag cache fallbacks
Address the Feature 06 integration review: - Offline bootstrap: the API client keeps a device-local verified-session marker (localStorage "1" only, never the /me payload or CSRF token), set when /me answers with a member and cleared on a 401 or a not-in-household 403. When /me fails with a network error and the marker is set, AuthBootstrap opens the saved pages read-only (writes are refused for want of a CSRF token) and loads /me again when the browser comes back online. /me runs with networkMode 'always' so an offline load fails fast instead of pausing on the skeleton. - Recipe reads (/api/v1/recipes) join the network-first API cache; /auth, settings and jobs stay network-only, including encoded and dot-segment forms. - The network-first strategy marks a response answered from its cache with X-MealQuest-Cache-Fallback; the client counts it as offline, so the indicator shows while navigator.onLine is still true. Tests: offline reload of /plan and /dinner/:id after an online visit, no saved pages without a verified session or after a 401, reconnect reloads /me; the real Workbox NetworkFirst strategy against an in-memory cache opens a previously viewed recipe offline, marked; the pill shows for a cache fallback while online. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiProvider, createApiClient, type ApiClient } from '../api/index.ts'
|
||||
import { ApiProvider, createApiClient, createVerifiedSession, type ApiClient } from '../api/index.ts'
|
||||
import { fakeLocation, jsonResponse, ME, stubFetch } from '../test/fetchStub.ts'
|
||||
import { cacheFallbackHeader } from '../sw/routes.ts'
|
||||
import { createNetworkStatus } from './networkStatus.ts'
|
||||
import { OfflinePill } from './OfflinePill.tsx'
|
||||
|
||||
@@ -30,7 +31,7 @@ function goOnline() {
|
||||
}
|
||||
|
||||
function renderPill(): ApiClient {
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window))
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
render(
|
||||
<ApiProvider client={api}>
|
||||
<OfflinePill />
|
||||
@@ -80,6 +81,21 @@ describe('OfflinePill', () => {
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows while online when the service worker answers from its cache', async () => {
|
||||
const api = renderPill()
|
||||
const cached = jsonResponse(200, { id: '01J9ZR' })
|
||||
cached.headers.set(cacheFallbackHeader, '1')
|
||||
stubFetch(cached, jsonResponse(200, { id: '01J9ZR' }))
|
||||
|
||||
await act(() => api.apiFetch('/api/v1/recipes/01J9ZR'))
|
||||
expect(navigator.onLine).toBe(true)
|
||||
expect(screen.getByRole('status')).toHaveTextContent(copy)
|
||||
|
||||
// The server answers again.
|
||||
await act(() => api.apiFetch('/api/v1/recipes/01J9ZR'))
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores a request body that cannot be serialized', async () => {
|
||||
const api = renderPill()
|
||||
const fetchMock = stubFetch(jsonResponse(200, ME))
|
||||
@@ -119,7 +135,7 @@ describe('OfflinePill', () => {
|
||||
it('removes its online and offline listeners on unmount', () => {
|
||||
const add = vi.spyOn(window, 'addEventListener')
|
||||
const remove = vi.spyOn(window, 'removeEventListener')
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window))
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
const { unmount } = render(
|
||||
<ApiProvider client={api}>
|
||||
<OfflinePill />
|
||||
|
||||
Reference in New Issue
Block a user