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>
174 lines
5.2 KiB
JavaScript
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)
|
|
})
|
|
})
|