tests/changelog-cache.unit.test.ts
Rama main · réplica de lectura del repositorio original.
import type { MiddlewareKeyVariables } from '../supabase/functions/_backend/utils/hono.ts'
import { Hono } from 'hono'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { changelogDigest, getReleases } from '../supabase/functions/_backend/private/changelog.ts'
import { dependencySource, getDependency } from '../supabase/functions/_backend/utils/changelog_dependencies.ts'
import { fetchAnonymousGithub } from '../supabase/functions/_backend/utils/changelog_github.ts'
const edge = new Map<string, { response: Response, expires: number }>()
const shared = new Map<string, { value: string, expires: number }>()
const kv = {
get: vi.fn(async (key: string) => {
const hit = shared.get(key)
return hit && hit.expires > Date.now() ? JSON.parse(hit.value) : null
}),
put: vi.fn(async (key: string, value: string, options: { expirationTtl: number }) => {
shared.set(key, { value, expires: Date.now() + options.expirationTtl * 1000 })
}),
}
const ai = { run: vi.fn() }
const fetchMock = vi.fn()
let notes: string
const app = new Hono<MiddlewareKeyVariables>()
app.use('*', async (c, next) => {
c.set('auth', { userId: c.req.header('X-Test-User') ?? '00000000-0000-4000-8000-000000000001', authType: 'jwt', apikey: null, jwt: null })
c.set('requestId', 'changelog-test')
await next()
})
app.post('/digest', async c => c.json(await changelogDigest(c, await c.req.json())))
app.get('/dependency', async c => c.json(await getDependency(c, c.req.query('name'))))
app.get('/releases', async c => c.json(await getReleases(c, { owner: 'ionic-team', repo: 'capacitor', tagPrefix: '' })))
app.get('/console-releases', async c => c.json(await getReleases(c, { repo: 'capgo.app', tagPrefix: 'capgo-' })))
app.get('/public-github', c => fetchAnonymousGithub(c, 'https://api.github.com/repos/example/plugin/releases', { 'User-Agent': 'capgo-app' }))
app.get('/plugin-releases', async (c) => {
const dependency = await getDependency(c, '@capacitor/camera')
if (!dependency.source)
throw new Error('Missing test repository')
return c.json(await getReleases(c, dependency.source))
})
function digest(body: Record<string, unknown> = {}, env: Record<string, unknown> = {}, user?: string) {
return app.request('https://api.example.com/digest', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...user ? { 'X-Test-User': user } : {} },
body: JSON.stringify({ product: 'capacitor', fromVersion: '8.0.0', toVersion: '8.1.0', ...body }),
}, { AI: ai, CHANNEL_SELF_STORE: kv, ...env })
}
beforeEach(() => {
edge.clear()
shared.clear()
vi.clearAllMocks()
notes = '### Bug Fixes\n* **android:** fix startup crash'
vi.stubGlobal('caches', {
open: async () => ({
match: async (key: Request) => {
const hit = edge.get(key.url)
return hit && hit.expires > Date.now() ? hit.response.clone() : undefined
},
put: async (key: Request, response: Response) => {
const ttl = Number(/s-maxage=(\d+)/.exec(response.headers.get('Cache-Control') ?? '')?.[1])
edge.set(key.url, { response: response.clone(), expires: Date.now() + ttl * 1000 })
},
}),
})
fetchMock.mockImplementation(async (url: string) => {
if (url.startsWith('https://registry.npmjs.org/'))
return Response.json({ name: '@capacitor/camera', version: '8.1.0', repository: { url: 'git+https://github.com/ionic-team/capacitor-camera.git' } })
return Response.json([{ tag_name: '8.1.0', body: notes, html_url: 'https://github.com/ionic-team/capacitor/releases/tag/8.1.0' }])
})
vi.stubGlobal('fetch', fetchMock)
ai.run.mockResolvedValue({ response: { headline: 'Android startup fixes.', highlights: [{ title: 'Startup crash', detail: 'Fixed on Android.', version: '8.1.0', type: 'fixed', impact: 'medium', relevant: false }], actionRequired: [] } })
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
vi.unstubAllGlobals()
vi.useRealTimers()
})
describe('shared changelog caching', () => {
it('shares concurrent release and AI misses, then reuses the summary for another user and colo', async () => {
const responses = await Promise.all([digest(), digest(), digest()])
for (const response of responses)
expect(await response.json()).toMatchObject({ aiGenerated: true })
expect(fetchMock).toHaveBeenCalledOnce()
expect(ai.run).toHaveBeenCalledOnce()
expect(kv.put).toHaveBeenCalledWith(expect.stringMatching(/^changelog:digest:/), expect.any(String), { expirationTtl: 604800 })
edge.clear()
expect(await (await digest({}, {}, '00000000-0000-4000-8000-000000000002')).json()).toMatchObject({ aiGenerated: true })
expect(ai.run).toHaveBeenCalledOnce()
expect(fetchMock).toHaveBeenCalledOnce()
})
it('invalidates summaries for edited notes, models, locales and version bounds', async () => {
await digest()
notes = '### Bug Fixes\n* **android:** fix startup crash and preserve settings'
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 901_000)
edge.clear()
await digest()
vi.stubEnv('CHANGELOG_MODEL', 'test-model')
await digest()
vi.unstubAllEnvs()
await digest({ locale: 'fr' })
await digest({ fromVersion: '8.0.1' })
expect(ai.run).toHaveBeenCalledTimes(5)
})
it('canonicalizes context order so equivalent requests reuse one AI summary', async () => {
await digest({ context: { platforms: ['ios', 'android'], features: ['delta', 'encryption'] } })
await digest({ context: { features: ['encryption', 'delta'], platforms: ['android', 'ios', 'android'] } })
expect(ai.run).toHaveBeenCalledOnce()
})
it('backs off briefly after AI failure and recovers instead of caching a fallback for a week', async () => {
ai.run.mockResolvedValueOnce({ response: '' })
expect(await (await digest()).json()).toMatchObject({ aiGenerated: false })
expect(await (await digest()).json()).toMatchObject({ aiGenerated: false })
expect(ai.run).toHaveBeenCalledOnce()
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 61_000)
expect(await (await digest()).json()).toMatchObject({ aiGenerated: true })
expect(ai.run).toHaveBeenCalledTimes(2)
})
})
describe('release fetch reliability', () => {
it('returns missing repositories as 404 without reporting server errors', async () => {
const log = vi.spyOn(console, 'error').mockImplementation(() => {})
fetchMock.mockResolvedValueOnce(Response.json({ message: 'Not Found' }, { status: 404 }))
expect((await app.request('https://api.example.com/releases')).status).toBe(404)
expect(log).not.toHaveBeenCalled()
})
it('backs off across anonymous repos while cached and authenticated releases remain available', async () => {
vi.stubEnv('GITHUB_TOKEN', 'fake-github-token')
const now = Date.now()
const clock = vi.spyOn(Date, 'now').mockReturnValue(now)
expect((await app.request('https://api.example.com/releases')).status).toBe(200)
fetchMock.mockResolvedValueOnce(Response.json({}, {
status: 403,
headers: { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': String(Math.ceil(now / 1000) + 120) },
}))
const limited = await app.request('https://api.example.com/public-github')
expect(limited.status).toBe(429)
expect(Number(limited.headers.get('retry-after'))).toBeGreaterThanOrEqual(120)
expect((await app.request('https://api.example.com/public-github')).status).toBe(429)
expect((await app.request('https://api.example.com/releases')).status).toBe(200)
expect(fetchMock).toHaveBeenCalledTimes(2)
// A different uncached dependency shares the anonymous quota, but not the private token.
expect((await app.request('https://api.example.com/plugin-releases')).status).toBe(429)
expect(fetchMock).toHaveBeenCalledTimes(3) // npm metadata only
expect((await app.request('https://api.example.com/console-releases')).status).toBe(200)
expect(fetchMock).toHaveBeenCalledTimes(4)
clock.mockReturnValue(now + 122_000)
expect((await app.request('https://api.example.com/public-github')).status).toBe(200)
expect(fetchMock).toHaveBeenCalledTimes(5)
for (const [url, options] of fetchMock.mock.calls) {
const parsed = new URL(url)
if (parsed.hostname === 'api.github.com' && !parsed.pathname.startsWith('/repos/Cap-go/'))
expect(options.headers).not.toHaveProperty('Authorization')
}
})
it.each<{ status: number, headers: Record<string, string>, message: string, seconds: number }>([
{ status: 429, headers: { 'retry-after': '120', 'x-ratelimit-remaining': '20', 'x-ratelimit-reset': '9999999999' }, message: '', seconds: 120 },
{ status: 429, headers: { 'retry-after': 'invalid' }, message: '', seconds: 60 },
{ status: 403, headers: {}, message: 'You have exceeded a secondary rate limit.', seconds: 60 },
])('respects secondary backoff for $status and $seconds seconds', async ({ status, headers, message, seconds }) => {
const now = Date.now()
const clock = vi.spyOn(Date, 'now').mockReturnValue(now)
fetchMock.mockResolvedValueOnce(Response.json({ message }, { status, headers }))
const response = await app.request('https://api.example.com/public-github')
expect(response.status).toBe(429)
expect(response.headers.get('retry-after')).toBe(String(seconds))
clock.mockReturnValue(now + (seconds - 1) * 1000)
expect((await app.request('https://api.example.com/public-github')).status).toBe(429)
expect(fetchMock).toHaveBeenCalledOnce()
clock.mockReturnValue(now + (seconds + 1) * 1000)
expect((await app.request('https://api.example.com/public-github')).status).toBe(200)
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('does not treat permission failures as a shared anonymous rate limit', async () => {
fetchMock.mockResolvedValueOnce(Response.json({ message: 'Forbidden' }, { status: 403 }))
expect((await app.request('https://api.example.com/public-github')).status).toBe(403)
expect((await app.request('https://api.example.com/public-github')).status).toBe(200)
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it.each(['network', 'server', 'rate-limit'])('does not cache partial history after a later page has a %s failure', async (failure) => {
const now = Date.now()
const clock = vi.spyOn(Date, 'now').mockReturnValue(now)
const firstPage = Array.from({ length: 100 }, (_, i) => ({ tag_name: `8.1.${i}`, body: notes }))
const request = () => app.request('https://api.example.com/releases', {}, { CHANNEL_SELF_STORE: kv })
fetchMock.mockResolvedValueOnce(Response.json(firstPage))
if (failure === 'network')
fetchMock.mockRejectedValueOnce(new Error('Connection failed'))
else
fetchMock.mockResolvedValueOnce(Response.json({}, { status: failure === 'server' ? 500 : 429 }))
const response = await request()
const payload = await response.json() as { truncated: boolean, releases: unknown[] }
expect(payload.truncated).toBe(true)
expect(payload.releases).toHaveLength(100)
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(kv.put).not.toHaveBeenCalled()
expect([...edge.keys()].some(key => new URL(key).pathname === '/private/changelog/releases')).toBe(false)
// Recovery must be visible before the normal 15-minute release TTL expires.
clock.mockReturnValue(now + 61_000)
fetchMock.mockResolvedValueOnce(Response.json(firstPage))
fetchMock.mockResolvedValueOnce(Response.json([{ tag_name: '8.0.0', body: notes }]))
const recovered = await (await request()).json() as { truncated: boolean, releases: unknown[] }
expect(recovered.truncated).toBe(false)
expect(recovered.releases).toHaveLength(101)
expect(fetchMock).toHaveBeenCalledTimes(4)
expect(kv.put).toHaveBeenCalledOnce()
edge.clear()
expect(await (await request()).json()).toEqual(recovered)
expect(fetchMock).toHaveBeenCalledTimes(4)
})
it('shares successful history truncated only by the page limit', async () => {
fetchMock.mockImplementation(async () => Response.json(Array.from({ length: 100 }, (_, i) => ({ tag_name: `8.1.${i}`, body: notes }))))
const request = () => app.request('https://api.example.com/releases', {}, { CHANNEL_SELF_STORE: kv })
const payload = await (await request()).json()
expect(payload).toMatchObject({ truncated: true })
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(kv.put).toHaveBeenCalledOnce()
edge.clear()
expect(await (await request()).json()).toEqual(payload)
expect(fetchMock).toHaveBeenCalledTimes(3)
})
it('does not cache an empty success when the CHANGELOG fallback is rate limited', async () => {
fetchMock.mockImplementation(async (url: string) => {
if (new URL(url).hostname === 'registry.npmjs.org')
return Response.json({ name: '@capacitor/camera', version: '8.1.0', repository: 'https://github.com/ionic-team/capacitor-camera' })
return url.includes('/contents/') ? Response.json({}, { status: 429 }) : Response.json([])
})
const response = await app.request('https://api.example.com/plugin-releases', {}, { CHANNEL_SELF_STORE: kv })
expect(response.status).toBe(429)
expect(kv.put).not.toHaveBeenCalled()
expect(fetchMock).toHaveBeenCalledTimes(3)
})
it('authenticates Capgo release requests and reuses public notes across locations until expiry', async () => {
vi.stubEnv('GITHUB_TOKEN', 'fake-github-token')
fetchMock.mockResolvedValue(Response.json([{ tag_name: 'capgo-8.1.0', body: notes }]))
const request = () => app.request('https://api.example.com/console-releases', {}, { CHANNEL_SELF_STORE: kv })
expect(await (await request()).json()).toMatchObject({ releases: [{ version: '8.1.0' }] })
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/Cap-go/capgo.app/releases'), expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer fake-github-token' }),
}))
expect(kv.put).toHaveBeenCalledWith(expect.any(String), expect.any(String), { expirationTtl: 900 })
edge.clear()
expect(await (await request()).json()).toMatchObject({ releases: [{ version: '8.1.0' }] })
expect(fetchMock).toHaveBeenCalledOnce()
// Neither the shared cache nor its edge copy may keep notes fresh indefinitely.
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 901_000)
fetchMock.mockResolvedValue(Response.json([{ tag_name: 'capgo-8.2.0', body: notes }]))
expect(await (await request()).json()).toMatchObject({ releases: [{ version: '8.2.0' }] })
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('logs rate-limit diagnostics without secrets and does not cache failed requests', async () => {
vi.stubEnv('GITHUB_TOKEN', 'fake-github-token')
const log = vi.spyOn(console, 'error').mockImplementation(() => {})
fetchMock.mockResolvedValueOnce(Response.json({ message: 'untrusted upstream body' }, {
status: 403,
headers: { 'x-ratelimit-limit': '5000', 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': '1800000000', 'x-github-request-id': 'test-request' },
}))
const request = () => app.request('https://api.example.com/console-releases', {}, { CHANNEL_SELF_STORE: kv })
expect((await request()).status).toBe(502)
expect(log.mock.calls.flat()).toEqual(expect.arrayContaining([
'Changelog GitHub release fetch failed', 'authenticated', true, 'rateLimitRemaining', '0', 'githubRequestId', 'test-request',
]))
expect(JSON.stringify(log.mock.calls)).not.toContain('fake-github-token')
expect(JSON.stringify(log.mock.calls)).not.toContain('untrusted upstream body')
expect(kv.put).not.toHaveBeenCalled()
fetchMock.mockResolvedValueOnce(Response.json([{ tag_name: 'capgo-8.1.0', body: notes }]))
expect(await (await request()).json()).toMatchObject({ releases: [{ version: '8.1.0' }] })
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('keeps fetching releases when the shared cache is unavailable', async () => {
kv.get.mockRejectedValueOnce(new Error('KV unavailable'))
kv.put.mockRejectedValueOnce(new Error('KV unavailable'))
const response = await app.request('https://api.example.com/releases', {}, { CHANNEL_SELF_STORE: kv })
expect(await response.json()).toMatchObject({ releases: [{ version: '8.1.0' }] })
})
})
describe('public dependency sources', () => {
it('summarizes third-party releases as their own dependency rather than a Capgo plugin', async () => {
await digest({ product: 'dependency', repo: '@capacitor/camera' })
expect(ai.run).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
messages: expect.arrayContaining([expect.objectContaining({ role: 'system', content: expect.stringContaining('a Capacitor dependency (ionic-team/capacitor-camera)') })]),
}))
})
it('uses and caches CHANGELOG.md when a plugin has no GitHub Releases', async () => {
fetchMock.mockImplementation(async (url: string) => {
if (new URL(url).hostname === 'registry.npmjs.org')
return Response.json({ name: '@capacitor/camera', version: '8.1.0', repository: { url: 'git+https://github.com/ionic-team/capacitor-camera.git' } })
if (url.includes('/contents/'))
return new Response('## [8.1.0] (2026-11-01)\n### Bug Fixes\n* Fix Android startup crash')
return Response.json([])
})
const request = () => app.request('https://api.example.com/plugin-releases', {}, { CHANNEL_SELF_STORE: kv })
const payload = await (await request()).json()
expect(payload).toMatchObject({ truncated: true, releases: [{ version: '8.1.0', sections: [{ type: 'fixed', items: ['Fix Android startup crash'] }] }] })
expect(kv.put).toHaveBeenCalledOnce()
await request()
expect(fetchMock).toHaveBeenCalledTimes(3)
edge.clear()
expect(await (await request()).json()).toEqual(payload)
expect(fetchMock).toHaveBeenCalledTimes(4) // npm metadata is fetched again, release notes come from KV
expect(ai.run).not.toHaveBeenCalled()
})
it('accepts npm GitHub coordinates and rejects credentials, other hosts and traversals', () => {
expect(dependencySource({ name: '@capacitor/camera', version: '8.0.0', repository: { url: 'git+https://github.com/ionic-team/capacitor-camera.git' } })).toMatchObject({ owner: 'ionic-team', repo: 'capacitor-camera' })
for (const url of ['https://github.com.evil.example/a/b', 'https://user:password@github.com/a/b', 'https://github.com/a/../../b', 'https://example.com/a/b'])
expect(dependencySource({ name: 'test-plugin', version: '1.0.0', repository: url })).toBeNull()
})
it('caches npm lookups and never forwards the GitHub token to external repositories', async () => {
const url = 'https://api.example.com/dependency?name=%40capacitor%2Fcamera'
await app.request(url)
await app.request(url)
expect(fetchMock).toHaveBeenCalledOnce()
vi.stubEnv('GITHUB_TOKEN', 'fake-github-token')
await app.request('https://api.example.com/releases')
expect(fetchMock).toHaveBeenLastCalledWith(expect.stringContaining('/ionic-team/capacitor/releases'), expect.objectContaining({ headers: expect.not.objectContaining({ Authorization: expect.anything() }) }))
})
it('rejects non-package input before making an outbound request', async () => {
await app.request('https://api.example.com/dependency?name=..%2Fsecret')
expect(fetchMock).not.toHaveBeenCalled()
})
})