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>
133 lines
4.6 KiB
JavaScript
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)
|
|
})
|
|
})
|