thats-me/frontend/test/vitest/image-cache.spec.js
Kevin Adametz ab23c9f4ef Client-Logging, getrennte Rate-Limits und Ladezustand für Bilder
Auf dem Testserver lief die App beim Scrollen langer Timelines in ein 429:
nach jeder Scroll-Pause ging sofort ein PUT /settings raus, und jedes
sichtbare Vorschaubild zählte auf dasselbe Limit von 300/min. Danach
blieben Bilder leer und "Einstellungen konnten nicht gespeichert werden"
erschien.

Backend: Medien-Reads bekommen einen eigenen Limiter (media, 1200/min),
POST /client-logs nimmt Warnungen und Fehler der App an (client-logs,
30/min) und schreibt sie in den täglichen Kanal `client`; Exceptions
tragen user_id, path und method im Kontext.

Frontend: Einstellungen gehen frühestens 1,5 s nach der letzten Änderung
und nur bei geändertem Inhalt zum Server, 429/Netzfehler werden still mit
Backoff wiederholt, nur eine Ablehnung wird gemeldet. Bilder laufen durch
ein Gate (6 parallel, Retry-After, 3 Versuche) und fallen auf einen
Platzhalter statt auf einen kaputten Pfad zurück. Neuer clientLog-Service
mit IndexedDB-Puffer, Dedupe und Batch-Versand, globaler Fehlerfang im
Boot, Protokoll im Entwickler-Tab. Drei pulsierende Punkte statt weißer
Fläche in Timeline-Punkt, Key Image und Medienkacheln.

Dokumentation in documentation/logging.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 09:31:35 +00:00

174 lines
5.2 KiB
JavaScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { db } from 'src/db'
import { deferred } from './helpers'
let apiFetchMock = vi.fn()
vi.mock('src/services/apiClient', async () => {
const actual = await vi.importActual('src/services/apiClient')
return {
...actual,
API_BASE: 'http://api.test',
apiFetch: (...args) => apiFetchMock(...args)
}
})
const { resolveFullRes, useImageCache, setImageCacheScope, resetImageCacheForTests } = await import('composables/useImageCache')
const { logEntries, resetClientLogForTests } = await import('src/services/clientLog')
function imageResponse() {
return new Response(new Blob(['jpeg'], { type: 'image/jpeg' }), { status: 200 })
}
function statusResponse(status, headers = {}) {
return new Response(null, { status, headers })
}
let objectUrls = 0
beforeEach(async () => {
apiFetchMock = vi.fn()
objectUrls = 0
vi.stubGlobal('URL', Object.assign(URL, {
createObjectURL: () => `blob:mock-${++objectUrls}`,
revokeObjectURL: () => {}
}))
resetClientLogForTests()
resetImageCacheForTests()
setImageCacheScope('image-user')
await db.scopedImageCache.clear()
// Timers and the clock the gate compares against; fake-indexeddb keeps
// the real event loop (setImmediate).
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
})
/**
* Lets the promise chains behind the gate and the IndexedDB look-ups run to
* the end. setImmediate is real, so each round drains the microtasks that
* came before it.
*/
async function settle(rounds = 6) {
for (let i = 0; i < rounds; i++) {
await new Promise(resolve => setImmediate(resolve))
}
}
/** Advances the fake clock, then lets everything it woke up finish. */
async function advance(ms) {
await vi.advanceTimersByTimeAsync(ms)
await settle()
}
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
setImageCacheScope(null)
})
describe('image cache — protected images', () => {
it('waits out a 429 and delivers the image on the next attempt', async () => {
apiFetchMock
.mockResolvedValueOnce(statusResponse(429, { 'Retry-After': '2' }))
.mockResolvedValueOnce(imageResponse())
const pending = resolveFullRes('/event-media/7/preview')
await settle()
expect(apiFetchMock).toHaveBeenCalledTimes(1)
// Retry-After says two seconds; nothing happens before that.
await advance(1500)
expect(apiFetchMock).toHaveBeenCalledTimes(1)
await advance(600)
expect(await pending).toBe('blob:mock-1')
expect(apiFetchMock).toHaveBeenCalledTimes(2)
expect(logEntries.value).toHaveLength(0)
})
it('gives up after three server errors with null, not with a path the img cannot load', async () => {
apiFetchMock.mockResolvedValue(statusResponse(500))
const pending = resolveFullRes('/event-media/7/preview')
await settle()
await advance(1500)
await advance(3000)
expect(await pending).toBeNull()
expect(apiFetchMock).toHaveBeenCalledTimes(3)
expect(logEntries.value).toHaveLength(1)
expect(logEntries.value[0]).toMatchObject({
level: 'warning',
scope: 'image',
context: { status: 500, url: '/event-media/7/preview' }
})
})
it('does not retry a 404', async () => {
apiFetchMock.mockResolvedValue(statusResponse(404))
const pending = resolveFullRes('/event-media/9/original')
await settle()
expect(await pending).toBeNull()
expect(apiFetchMock).toHaveBeenCalledTimes(1)
})
it('lets at most six image requests run at once', async () => {
const gates = []
let inFlight = 0
let peak = 0
apiFetchMock.mockImplementation(() => {
inFlight += 1
peak = Math.max(peak, inFlight)
const gate = deferred()
gates.push(gate)
return gate.promise.then(() => {
inFlight -= 1
return imageResponse()
})
})
const all = Promise.all(Array.from({ length: 10 }, (_, i) => resolveFullRes(`/event-media/${i}/thumb`)))
await settle()
expect(apiFetchMock).toHaveBeenCalledTimes(6)
for (const gate of gates.splice(0)) gate.resolve()
await settle()
expect(apiFetchMock).toHaveBeenCalledTimes(10)
for (const gate of gates.splice(0)) gate.resolve()
const results = await all
expect(results.every(src => src?.startsWith('blob:mock-'))).toBe(true)
expect(peak).toBe(6)
})
})
describe('image cache — composable', () => {
it('reports a failure instead of showing a broken image', async () => {
apiFetchMock.mockResolvedValue(statusResponse(404))
const { resolvedSrc, loading, failed } = useImageCache('/event-media/3/thumb', 'e3')
await nextTick()
expect(loading.value).toBe(true)
await settle(10)
expect(resolvedSrc.value).toBeNull()
expect(failed.value).toBe(true)
expect(loading.value).toBe(false)
})
it('falls back to the direct URL for public images', async () => {
vi.stubGlobal('fetch', vi.fn(async () => statusResponse(500)))
const { resolvedSrc, failed } = useImageCache('https://cdn.test/photo.jpg', 'e4')
await settle(10)
// A 500 is retried twice before the cache gives up on it.
await advance(1500)
await advance(3000)
expect(resolvedSrc.value).toBe('https://cdn.test/photo.jpg')
expect(failed.value).toBe(false)
})
})