Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com> Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #51.
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { cacheNames, routeFor, staleCaches, type Route } from './routes'
|
||||
|
||||
const origin = 'https://mealquest.test'
|
||||
|
||||
function route(path: string, method = 'GET', mode: RequestMode = 'cors'): Route {
|
||||
return routeFor({ method, url: new URL(path, origin), mode }, origin)
|
||||
}
|
||||
|
||||
const apiCache: Route = { strategy: 'network-first', cacheName: cacheNames.api }
|
||||
const networkOnly: Route = { strategy: 'network-only' }
|
||||
|
||||
describe('routeFor', () => {
|
||||
it.each([
|
||||
'/api/v1/plans',
|
||||
'/api/v1/plans/2026-W40',
|
||||
'/api/v1/shopping/current',
|
||||
'/api/v1/dinners',
|
||||
'/api/v1/dinners/01J9ZK?include=recipe',
|
||||
'/api/v1/home',
|
||||
])('GET %s is network-first in the API cache', (path) => {
|
||||
expect(route(path)).toEqual(apiCache)
|
||||
})
|
||||
|
||||
it.each(['/media/photos/abc.jpg', '/media/thumbs/abc.webp'])(
|
||||
'GET %s is cache-first in the media cache',
|
||||
(path) => {
|
||||
expect(route(path)).toEqual({ strategy: 'cache-first', cacheName: cacheNames.media })
|
||||
},
|
||||
)
|
||||
|
||||
it('GET of a font file is cache-first in the fonts cache', () => {
|
||||
expect(route('/assets/Figtree-Bold-a1b2c3.woff2')).toEqual({
|
||||
strategy: 'cache-first',
|
||||
cacheName: cacheNames.fonts,
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
'/auth/login',
|
||||
'/auth/callback?code=x&state=y',
|
||||
'/auth/logout',
|
||||
'/auth/me',
|
||||
'/api/v1/settings',
|
||||
'/api/v1/settings/ai',
|
||||
'/api/v1/jobs',
|
||||
'/api/v1/jobs/01J9ZK',
|
||||
])('GET %s is network-only', (path) => {
|
||||
expect(route(path)).toEqual(networkOnly)
|
||||
})
|
||||
|
||||
it.each(['/auth/login', '/auth/callback?code=x&state=y', '/api/v1/settings/ai'])(
|
||||
'a navigation to %s is network-only with no offline fallback',
|
||||
(path) => {
|
||||
expect(route(path, 'GET', 'navigate')).toEqual(networkOnly)
|
||||
},
|
||||
)
|
||||
|
||||
it.each([
|
||||
'/%61uth/login',
|
||||
'/auth%2Fcallback?code=x&state=y',
|
||||
'/%61pi/v1/settings/household',
|
||||
'/api/v1/%73ettings/ai',
|
||||
'/api/v1/settings%2Fai',
|
||||
'/%61pi/v1/jobs/01J9ZK',
|
||||
'/api/v1/j%6Fbs',
|
||||
'/api/v1/plans%2F..%2Fsettings',
|
||||
'/plan%2F..%2Fauth%2Flogin',
|
||||
])('an encoded reserved path %s is network-only, navigated or not', (path) => {
|
||||
expect(route(path)).toEqual(networkOnly)
|
||||
expect(route(path, 'GET', 'navigate')).toEqual(networkOnly)
|
||||
})
|
||||
|
||||
it.each(['/%E0%A4%A', '/auth%', '/plan/%zz', '/api/v1/plans/%C3'])(
|
||||
'a malformed encoding in %s is network-only, navigated or not',
|
||||
(path) => {
|
||||
expect(route(path)).toEqual(networkOnly)
|
||||
expect(route(path, 'GET', 'navigate')).toEqual(networkOnly)
|
||||
},
|
||||
)
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
it.each(['POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'])(
|
||||
'%s is network-only on every URL class',
|
||||
(method) => {
|
||||
for (const path of [
|
||||
'/api/v1/plans/2026-W40',
|
||||
'/api/v1/shopping/current',
|
||||
'/api/v1/dinners/01J9ZK',
|
||||
'/api/v1/home',
|
||||
'/media/photos/abc.jpg',
|
||||
'/assets/Figtree-Bold-a1b2c3.woff2',
|
||||
'/auth/logout',
|
||||
'/plan',
|
||||
]) {
|
||||
expect(route(path, method), `${method} ${path}`).toEqual(networkOnly)
|
||||
expect(route(path, method, 'navigate'), `${method} ${path}`).toEqual(networkOnly)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it('matches whole path segments only', () => {
|
||||
expect(route('/api/v1/plansx')).toEqual(networkOnly)
|
||||
expect(route('/api/v1/homepage')).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/ideas', 'GET', 'navigate')).toEqual(networkOnly)
|
||||
})
|
||||
|
||||
it('leaves cross-origin requests network-only', () => {
|
||||
for (const path of ['/api/v1/plans', '/media/a.jpg', '/assets/a.woff2']) {
|
||||
const url = new URL(path, 'https://cdn.example')
|
||||
expect(routeFor({ method: 'GET', url, mode: 'no-cors' }, origin)).toEqual(networkOnly)
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['/', '/plan', '/supply-run', '/dinners/01J9ZK'])(
|
||||
'a navigation to the app at %s falls back to the precached index.html',
|
||||
(path) => {
|
||||
expect(route(path, 'GET', 'navigate')).toEqual({ strategy: 'navigation-fallback' })
|
||||
},
|
||||
)
|
||||
|
||||
it('leaves other same-origin GETs network-only', () => {
|
||||
expect(route('/health')).toEqual(networkOnly)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cache names', () => {
|
||||
it('all start with mealquest-', () => {
|
||||
for (const name of Object.values(cacheNames)) expect(name).toMatch(/^mealquest-/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('staleCaches', () => {
|
||||
it('deletes only MealQuest caches that are not current', () => {
|
||||
const current = ['mealquest-precache-v1', ...Object.values(cacheNames)]
|
||||
const names = [
|
||||
...current,
|
||||
'mealquest-precache',
|
||||
'mealquest-api-v0',
|
||||
'workbox-precache-v2-https://mealquest.test/',
|
||||
'other-app-cache',
|
||||
'my-mealquest-cache',
|
||||
]
|
||||
expect(staleCaches(names, current)).toEqual(['mealquest-precache', 'mealquest-api-v0'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
// Decides how the service worker handles each request. This module is pure —
|
||||
// no Workbox, no service worker globals — so the tests can assert every URL
|
||||
// class without a worker; src/sw.ts maps each route to a Workbox strategy.
|
||||
//
|
||||
// Security rule: /auth/*, /api/v1/settings/*, /api/v1/jobs/* and every
|
||||
// non-GET request are network-only and never reach a cache. Paths are matched
|
||||
// as the server sees them — percent-decoded, before and after dot segments are
|
||||
// resolved — so /%61uth/login is /auth/login; a malformed encoding is
|
||||
// network-only.
|
||||
|
||||
export type Route =
|
||||
// Network with a 3 s timeout, then the last cached response.
|
||||
| { strategy: 'network-first'; cacheName: typeof cacheNames.api }
|
||||
// Cached response when there is one, else the network; entries expire.
|
||||
| { strategy: 'cache-first'; cacheName: typeof cacheNames.media | typeof cacheNames.fonts }
|
||||
// Network only, nothing cached.
|
||||
| { strategy: 'network-only' }
|
||||
// Network only, nothing cached; offline, the precached index.html.
|
||||
| { strategy: 'navigation-fallback' }
|
||||
|
||||
export interface RouteRequest {
|
||||
method: string
|
||||
url: URL
|
||||
mode: RequestMode
|
||||
}
|
||||
|
||||
// Bumping the version renames every cache; activate then deletes the old ones.
|
||||
export const cacheVersion = 'v1'
|
||||
export const cachePrefix = 'mealquest-'
|
||||
|
||||
export const cacheNames = {
|
||||
api: `${cachePrefix}api-${cacheVersion}`,
|
||||
media: `${cachePrefix}media-${cacheVersion}`,
|
||||
fonts: `${cachePrefix}fonts-${cacheVersion}`,
|
||||
} as const
|
||||
|
||||
export const networkTimeoutSeconds = 3
|
||||
|
||||
const neverCached = ['/auth', '/api/v1/settings', '/api/v1/jobs']
|
||||
const networkFirst = ['/api/v1/plans', '/api/v1/shopping', '/api/v1/dinners', '/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.
|
||||
function under(path: string, prefix: string): boolean {
|
||||
return path === prefix || path.startsWith(`${prefix}/`)
|
||||
}
|
||||
|
||||
// The pathname percent-decoded, as the server's router sees it, or undefined
|
||||
// when the encoding is malformed.
|
||||
function decodePath(pathname: string): string | undefined {
|
||||
try {
|
||||
return decodeURIComponent(pathname)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Resolves "." and ".." segments the way the server's path.Clean does, so a
|
||||
// decoded %2F cannot carry a request out of a reserved prefix:
|
||||
// /api/v1/plans/../settings cleans to /api/v1/settings.
|
||||
function cleanPath(path: string): string {
|
||||
const segments: string[] = []
|
||||
for (const segment of path.split('/')) {
|
||||
if (segment === '' || segment === '.') continue
|
||||
if (segment === '..') segments.pop()
|
||||
else segments.push(segment)
|
||||
}
|
||||
return `/${segments.join('/')}`
|
||||
}
|
||||
|
||||
const networkOnly: Route = { strategy: 'network-only' }
|
||||
|
||||
export function routeFor(request: RouteRequest, origin: string): Route {
|
||||
const { method, url, mode } = request
|
||||
if (method !== 'GET') return networkOnly
|
||||
if (url.origin !== origin) return networkOnly
|
||||
const path = decodePath(url.pathname)
|
||||
if (path === undefined) return networkOnly
|
||||
const cleaned = cleanPath(path)
|
||||
const reserved = (prefix: string) => under(path, prefix) || under(cleaned, prefix)
|
||||
if (neverCached.some(reserved)) return networkOnly
|
||||
if (networkFirst.some((prefix) => under(path, prefix))) {
|
||||
return { strategy: 'network-first', cacheName: cacheNames.api }
|
||||
}
|
||||
if (under(path, '/media')) return { strategy: 'cache-first', cacheName: cacheNames.media }
|
||||
if (path.endsWith('.woff2')) return { strategy: 'cache-first', cacheName: cacheNames.fonts }
|
||||
if (reserved('/api')) return networkOnly
|
||||
if (mode === 'navigate') return { strategy: 'navigation-fallback' }
|
||||
return networkOnly
|
||||
}
|
||||
|
||||
// The caches activate deletes: MealQuest caches that are no longer current.
|
||||
// Caches without the mealquest- prefix belong to someone else and are kept.
|
||||
export function staleCaches(names: readonly string[], current: readonly string[]): string[] {
|
||||
return names.filter((name) => name.startsWith(cachePrefix) && !current.includes(name))
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// 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.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import source from '../sw.ts?raw'
|
||||
|
||||
describe('service worker source', () => {
|
||||
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: [^}]+\}\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the precached font when the network fails', () => {
|
||||
expect(source).toContain('handlerDidError: async ({ request }) => matchPrecache(request.url)')
|
||||
})
|
||||
|
||||
it('registers the font route before the precache route', () => {
|
||||
const fontRoute = source.indexOf("registerRoute(isFont, handle, 'GET')")
|
||||
const precache = source.indexOf('precacheAndRoute(self.__WB_MANIFEST)')
|
||||
expect(fontRoute).toBeGreaterThanOrEqual(0)
|
||||
expect(precache).toBeGreaterThan(fontRoute)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user