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:
@@ -0,0 +1,96 @@
|
||||
// Runs the real Workbox network-first strategy against an in-memory cache
|
||||
// storage: jsdom has none, and the worker itself needs service worker globals.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RouteHandlerCallbackOptions } from 'workbox-core/types'
|
||||
import { createApiStrategy } from './apiStrategy'
|
||||
import { cacheFallbackHeader, cacheNames, routeFor } from './routes'
|
||||
|
||||
const origin = 'https://mealquest.test'
|
||||
|
||||
// FakeCache keys entries by URL, which is all the API cache needs.
|
||||
class FakeCache {
|
||||
readonly entries = new Map<string, Response>()
|
||||
async match(request: RequestInfo | URL): Promise<Response | undefined> {
|
||||
return this.entries.get(new Request(request).url)?.clone()
|
||||
}
|
||||
async put(request: RequestInfo | URL, response: Response): Promise<void> {
|
||||
this.entries.set(new Request(request).url, response)
|
||||
}
|
||||
}
|
||||
|
||||
// FakeExtendableEvent stands in for the worker-only ExtendableEvent,
|
||||
// collecting the promises the strategy waits on.
|
||||
class FakeExtendableEvent extends Event {
|
||||
readonly pending: Promise<unknown>[] = []
|
||||
waitUntil(promise: Promise<unknown>) {
|
||||
this.pending.push(promise)
|
||||
}
|
||||
}
|
||||
|
||||
let cache: FakeCache
|
||||
|
||||
beforeEach(() => {
|
||||
cache = new FakeCache()
|
||||
// Workbox checks its arguments against these worker-only globals.
|
||||
vi.stubGlobal('FetchEvent', class FetchEvent {})
|
||||
vi.stubGlobal('ExtendableEvent', FakeExtendableEvent)
|
||||
const only = (name: string | undefined) => {
|
||||
if (name !== cacheNames.api) throw new Error(`unexpected cache ${name}`)
|
||||
return cache
|
||||
}
|
||||
vi.stubGlobal('caches', {
|
||||
open: async (name: string) => only(name),
|
||||
match: async (request: RequestInfo | URL, options?: MultiCacheQueryOptions) =>
|
||||
only(options?.cacheName).match(request),
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// handle sends a GET through the API strategy, as src/sw.ts does for a
|
||||
// network-first route.
|
||||
async function handle(path: string): Promise<Response> {
|
||||
const request = new Request(new URL(path, origin))
|
||||
expect(routeFor({ method: 'GET', url: new URL(request.url), mode: 'cors' }, origin)).toEqual({
|
||||
strategy: 'network-first',
|
||||
cacheName: cacheNames.api,
|
||||
})
|
||||
const event = new FakeExtendableEvent('fetch')
|
||||
const response = await createApiStrategy().handle({ request, event: event as unknown as RouteHandlerCallbackOptions['event'] })
|
||||
await Promise.all(event.pending)
|
||||
return response
|
||||
}
|
||||
|
||||
function recipe(): Response {
|
||||
return new Response(JSON.stringify({ id: '01J9ZR', name: 'Chicken tikka masala' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('API strategy', () => {
|
||||
it('answers from the network while the server is reachable, unmarked', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockResolvedValue(recipe()))
|
||||
const response = await handle('/api/v1/recipes/01J9ZR')
|
||||
expect(response.headers.has(cacheFallbackHeader)).toBe(false)
|
||||
expect(await response.json()).toMatchObject({ id: '01J9ZR' })
|
||||
expect(cache.entries.has(`${origin}/api/v1/recipes/01J9ZR`)).toBe(true)
|
||||
})
|
||||
|
||||
it('opens a previously viewed recipe offline, marked as a cache fallback', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockResolvedValue(recipe()))
|
||||
await handle('/api/v1/recipes/01J9ZR')
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
|
||||
const response = await handle('/api/v1/recipes/01J9ZR')
|
||||
expect(response.headers.get(cacheFallbackHeader)).toBe('1')
|
||||
expect(await response.json()).toEqual({ id: '01J9ZR', name: 'Chicken tikka masala' })
|
||||
})
|
||||
|
||||
it('fails offline for a recipe never viewed', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
|
||||
await expect(handle('/api/v1/recipes/01J9ZS')).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
// The network-first strategy for API reads, kept apart from src/sw.ts so the
|
||||
// tests can run it without a service worker.
|
||||
import type { WorkboxPlugin } from 'workbox-core/types'
|
||||
import { NetworkFirst } from 'workbox-strategies'
|
||||
import { cacheNames, markCacheFallback, networkTimeoutSeconds } from './routes'
|
||||
|
||||
// NetworkFirst reads its cache only when the network fails or times out, so
|
||||
// every cached response it uses is a fallback.
|
||||
const markFallback: WorkboxPlugin = {
|
||||
cachedResponseWillBeUsed: async ({ cachedResponse }) =>
|
||||
cachedResponse === undefined ? undefined : markCacheFallback(cachedResponse),
|
||||
}
|
||||
|
||||
export function createApiStrategy(): NetworkFirst {
|
||||
return new NetworkFirst({ cacheName: cacheNames.api, networkTimeoutSeconds, plugins: [markFallback] })
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { cacheNames, routeFor, staleCaches, type Route } from './routes'
|
||||
import { cacheFallbackHeader, cacheNames, markCacheFallback, routeFor, staleCaches, type Route } from './routes'
|
||||
|
||||
const origin = 'https://mealquest.test'
|
||||
|
||||
@@ -17,6 +17,8 @@ describe('routeFor', () => {
|
||||
'/api/v1/shopping/current',
|
||||
'/api/v1/dinners',
|
||||
'/api/v1/dinners/01J9ZK?include=recipe',
|
||||
'/api/v1/recipes',
|
||||
'/api/v1/recipes/01J9ZR',
|
||||
'/api/v1/home',
|
||||
])('GET %s is network-first in the API cache', (path) => {
|
||||
expect(route(path)).toEqual(apiCache)
|
||||
@@ -65,6 +67,8 @@ describe('routeFor', () => {
|
||||
'/%61pi/v1/jobs/01J9ZK',
|
||||
'/api/v1/j%6Fbs',
|
||||
'/api/v1/plans%2F..%2Fsettings',
|
||||
'/api/v1/recipes%2F..%2Fsettings%2Fai',
|
||||
'/api/v1/recipes/..%2F..%2F..%2Fauth%2Flogin',
|
||||
'/plan%2F..%2Fauth%2Flogin',
|
||||
])('an encoded reserved path %s is network-only, navigated or not', (path) => {
|
||||
expect(route(path)).toEqual(networkOnly)
|
||||
@@ -81,7 +85,8 @@ describe('routeFor', () => {
|
||||
|
||||
it('classifies other API paths by their decoded form', () => {
|
||||
expect(route('/%61pi/v1/plans/2026-W40')).toEqual(apiCache)
|
||||
expect(route('/%61pi/v1/recipes', 'GET', 'navigate')).toEqual(networkOnly)
|
||||
expect(route('/%61pi/v1/recipes/01J9ZR')).toEqual(apiCache)
|
||||
expect(route('/%61pi/v1/ideas', 'GET', 'navigate')).toEqual(networkOnly)
|
||||
})
|
||||
|
||||
it.each(['POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'])(
|
||||
@@ -91,6 +96,7 @@ describe('routeFor', () => {
|
||||
'/api/v1/plans/2026-W40',
|
||||
'/api/v1/shopping/current',
|
||||
'/api/v1/dinners/01J9ZK',
|
||||
'/api/v1/recipes/01J9ZR',
|
||||
'/api/v1/home',
|
||||
'/media/photos/abc.jpg',
|
||||
'/assets/Figtree-Bold-a1b2c3.woff2',
|
||||
@@ -106,13 +112,14 @@ describe('routeFor', () => {
|
||||
it('matches whole path segments only', () => {
|
||||
expect(route('/api/v1/plansx')).toEqual(networkOnly)
|
||||
expect(route('/api/v1/homepage')).toEqual(networkOnly)
|
||||
expect(route('/api/v1/recipesx')).toEqual(networkOnly)
|
||||
expect(route('/mediax/a.jpg')).toEqual(networkOnly)
|
||||
expect(route('/authors')).toEqual(networkOnly)
|
||||
expect(route('/api/v1/settingsx')).toEqual(networkOnly)
|
||||
})
|
||||
|
||||
it('leaves other API calls network-only', () => {
|
||||
expect(route('/api/v1/recipes')).toEqual(networkOnly)
|
||||
expect(route('/api/v1/me')).toEqual(networkOnly)
|
||||
expect(route('/api/v1/ideas', 'GET', 'navigate')).toEqual(networkOnly)
|
||||
})
|
||||
|
||||
@@ -155,3 +162,19 @@ describe('staleCaches', () => {
|
||||
expect(staleCaches(names, current)).toEqual(['mealquest-precache', 'mealquest-api-v0'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('markCacheFallback', () => {
|
||||
it('copies the cached response with the cache fallback header set', async () => {
|
||||
const cached = new Response('{"id":"01J9ZR"}', {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
const marked = markCacheFallback(cached)
|
||||
expect(marked.status).toBe(200)
|
||||
expect(marked.statusText).toBe('OK')
|
||||
expect(marked.headers.get('Content-Type')).toBe('application/json')
|
||||
expect(marked.headers.get(cacheFallbackHeader)).toBe('1')
|
||||
expect(await marked.json()).toEqual({ id: '01J9ZR' })
|
||||
})
|
||||
})
|
||||
|
||||
+13
-1
@@ -36,8 +36,20 @@ export const cacheNames = {
|
||||
|
||||
export const networkTimeoutSeconds = 3
|
||||
|
||||
// cacheFallbackHeader marks a network-first response answered from the cache
|
||||
// because the server could not be reached in time, so the app can show the
|
||||
// offline indicator even while the browser reports that it is online.
|
||||
export const cacheFallbackHeader = 'X-MealQuest-Cache-Fallback'
|
||||
|
||||
// markCacheFallback copies a cached response with cacheFallbackHeader set.
|
||||
export function markCacheFallback(cached: Response): Response {
|
||||
const headers = new Headers(cached.headers)
|
||||
headers.set(cacheFallbackHeader, '1')
|
||||
return new Response(cached.body, { status: cached.status, statusText: cached.statusText, headers })
|
||||
}
|
||||
|
||||
const neverCached = ['/auth', '/api/v1/settings', '/api/v1/jobs']
|
||||
const networkFirst = ['/api/v1/plans', '/api/v1/shopping', '/api/v1/dinners', '/api/v1/home']
|
||||
const networkFirst = ['/api/v1/plans', '/api/v1/shopping', '/api/v1/dinners', '/api/v1/recipes', '/api/v1/home']
|
||||
|
||||
// True when path is prefix itself or a path below it: /api/v1/plans matches
|
||||
// /api/v1/plans and /api/v1/plans/2026-W40, never /api/v1/plansx.
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
// The service worker itself needs worker globals, so these tests read its
|
||||
// source: they pin the parts routes.ts cannot express — the font strategy's
|
||||
// expiration and the font route's place ahead of the precache route.
|
||||
// source: they pin the parts routes.ts cannot express — the API cache's
|
||||
// strategy, the font strategy's expiration and the font route's place ahead of
|
||||
// the precache route.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import source from '../sw.ts?raw'
|
||||
|
||||
describe('service worker source', () => {
|
||||
it('serves the API cache with the strategy that marks cache fallbacks', () => {
|
||||
expect(source).toContain('[cacheNames.api]: createApiStrategy(),')
|
||||
})
|
||||
|
||||
it('gives the fonts cache a CacheFirst strategy with an expiration plugin', () => {
|
||||
expect(source).toMatch(
|
||||
/\[cacheNames\.fonts\]: new CacheFirst\(\{\s*cacheName: cacheNames\.fonts,\s*plugins: \[\s*new ExpirationPlugin\(\{ maxEntries: \d+, maxAgeSeconds: [^}]+\}\)/,
|
||||
|
||||
Reference in New Issue
Block a user