thats-me/frontend/test/vitest/settings-persist.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

133 lines
4.6 KiB
JavaScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { createApiRouter } from './helpers'
let api = createApiRouter()
vi.mock('src/services/apiClient', () => ({
API_BASE: 'http://api.test',
apiJson: (...args) => api.apiJson(...args),
apiFetch: vi.fn(),
retryAfterMs: () => null,
getToken: vi.fn(async () => 'token'),
setToken: vi.fn(async () => {}),
clearToken: vi.fn(async () => {}),
onUnauthorized: vi.fn(() => () => {}),
revokeToken: vi.fn(async () => {}),
loginRemote: vi.fn(),
ApiError: class ApiError extends Error {}
}))
const { useAuthStore } = await import('stores/auth')
const { useSettingsStore } = await import('stores/settings')
const { resetClientLogForTests, logEntries } = await import('src/services/clientLog')
function apiError(status, extra = {}) {
return Object.assign(new Error(`HTTP ${status}`), { status, ...extra })
}
describe('settings store — the server copy is written sparingly', () => {
let settingsStore
let puts
beforeEach(async () => {
setActivePinia(createPinia())
api = createApiRouter()
resetClientLogForTests()
puts = []
// Only the timers: IndexedDB needs the real event loop.
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
api.always('/settings', (url, options) => {
if (options.method !== 'PUT') return Promise.resolve({ data: null })
puts.push(options.body.settings)
return Promise.resolve({ data: options.body.settings })
})
const authStore = useAuthStore()
settingsStore = useSettingsStore()
authStore.currentUserId = 'remote-user-1'
// The GET at start-up and the watchers behind it.
await vi.advanceTimersByTimeAsync(2000)
puts.length = 0
})
afterEach(() => {
vi.useRealTimers()
})
it('bundles a burst of scroll positions into one PUT and skips identical payloads', async () => {
for (let i = 1; i <= 20; i++) {
settingsStore.saveTimelineScrollLeft(i * 10, true)
await vi.advanceTimersByTimeAsync(100)
}
// Two seconds of scrolling, not a single request yet.
expect(puts).toHaveLength(0)
await vi.advanceTimersByTimeAsync(1500)
expect(puts).toHaveLength(1)
expect(puts[0].timelineScrollLeft).toBe(200)
// The same value again changes nothing on the server.
settingsStore.saveTimelineScrollLeft(200, true)
await vi.advanceTimersByTimeAsync(3000)
expect(puts).toHaveLength(1)
settingsStore.saveTimelineScrollLeft(260, true)
await vi.advanceTimersByTimeAsync(1500)
expect(puts).toHaveLength(2)
expect(puts[1].timelineScrollLeft).toBe(260)
})
it('retries a 429 quietly instead of telling the user the settings were lost', async () => {
let attempts = 0
api = createApiRouter()
api.always('/settings', (url, options) => {
if (options.method !== 'PUT') return Promise.resolve({ data: null })
attempts += 1
if (attempts === 1) return Promise.reject(apiError(429, { retryAfterMs: 2000 }))
return Promise.resolve({ data: options.body.settings })
})
settingsStore.saveTimelineScrollLeft(50, true)
await vi.advanceTimersByTimeAsync(1500)
expect(attempts).toBe(1)
expect(settingsStore.lastError).toBeNull()
expect(logEntries.value.at(-1)).toMatchObject({ level: 'warning', scope: 'settings', context: { status: 429, attempt: 1 } })
// Backoff (3 s) outlasts the Retry-After here; then it goes out again.
await vi.advanceTimersByTimeAsync(2500)
expect(attempts).toBe(1)
await vi.advanceTimersByTimeAsync(600)
expect(attempts).toBe(2)
expect(settingsStore.lastError).toBeNull()
// A change made while the retry was pending is included, not lost.
settingsStore.saveTimelineScrollLeft(80, true)
await vi.advanceTimersByTimeAsync(1500)
expect(attempts).toBe(3)
})
it('reports a payload the server rejects', async () => {
api = createApiRouter()
api.always('/settings', (url, options) => (
options.method === 'PUT'
? Promise.reject(apiError(422))
: Promise.resolve({ data: null })
))
settingsStore.saveTimelineScrollLeft(50, true)
await vi.advanceTimersByTimeAsync(1500)
expect(settingsStore.lastError).toBe('Die Einstellungen konnten nicht gespeichert werden.')
expect(logEntries.value.at(-1)).toMatchObject({ level: 'error', scope: 'settings' })
})
it('sends nothing more once another store has taken over', async () => {
settingsStore.saveTimelineScrollLeft(50, true)
// A new Pinia, as the next test would create it.
setActivePinia(createPinia())
await vi.advanceTimersByTimeAsync(3000)
expect(puts).toHaveLength(0)
})
})