import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { db } from 'src/db' import { clientLog, logEntries, normalizeContext, resetClientLogForTests, restoreClientLog, setClientLogTransport, shipClientLog } from 'src/services/clientLog' import { flush, waitFor } from './helpers' beforeEach(async () => { resetClientLogForTests() await db.clientLogs.clear() }) afterEach(() => { resetClientLogForTests() }) describe('client log — collecting', () => { it('bumps a counter for the same message instead of adding a line', () => { clientLog.warn('image', 'Bild konnte nicht geladen werden (429)', { status: 429 }) clientLog.warn('image', 'Bild konnte nicht geladen werden (429)', { status: 429 }) clientLog.warn('image', 'Bild konnte nicht geladen werden (429)') clientLog.error('api', 'PUT /settings → 429', { status: 429 }) expect(logEntries.value).toHaveLength(2) expect(logEntries.value[0]).toMatchObject({ level: 'warning', scope: 'image', count: 3, shipped: 0 }) expect(logEntries.value[1]).toMatchObject({ level: 'error', scope: 'api', count: 1, shipped: 0 }) }) it('keeps info entries local and flattens errors into scalar context', () => { const entry = clientLog.info('sync', 'Warteschlange geleert') expect(entry.shipped).toBe(1) const error = Object.assign(new Error('Too Many Requests'), { status: 429 }) error.name = 'ApiError' expect(normalizeContext(error)).toMatchObject({ error: 'ApiError: Too Many Requests', status: 429 }) expect(normalizeContext({ nested: { a: 1 }, ok: true, skipped: undefined, empty: '' })) .toEqual({ nested: '{"a":1}', ok: true }) expect(normalizeContext(null)).toBeNull() }) }) describe('client log — shipping', () => { it('sends unshipped warnings and errors as one batch and marks them', async () => { const send = vi.fn(async () => {}) clientLog.error('api', 'PUT /settings → 429', { status: 429 }) clientLog.warn('image', 'Bild konnte nicht geladen werden (429)') clientLog.warn('image', 'Bild konnte nicht geladen werden (429)') clientLog.info('sync', 'nur lokal') setClientLogTransport({ appVersion: '0.0.1', enabled: () => true, send }) expect(await shipClientLog()).toBe(true) expect(send).toHaveBeenCalledTimes(1) const [batch] = send.mock.calls[0] expect(batch.appVersion).toBe('0.0.1') expect(batch.entries).toHaveLength(2) expect(batch.entries[0]).toMatchObject({ level: 'error', scope: 'api', message: 'PUT /settings → 429', context: { status: 429 } }) expect(batch.entries[1].message).toBe('Bild konnte nicht geladen werden (429) (×2)') expect(batch.entries[0].occurredAt).toMatch(/^\d{4}-\d{2}-\d{2}T/) expect(logEntries.value.every(entry => entry.shipped === 1)).toBe(true) // Nothing left: the next call sends nothing. expect(await shipClientLog()).toBe(false) expect(send).toHaveBeenCalledTimes(1) }) it('keeps everything in the browser when the transport says no (demo user)', async () => { const send = vi.fn(async () => {}) clientLog.error('window', 'crash') setClientLogTransport({ enabled: () => false, send }) expect(await shipClientLog()).toBe(false) expect(send).not.toHaveBeenCalled() expect(logEntries.value[0].shipped).toBe(0) }) it('stops after the server rejects the batch, but retries after a hiccup', async () => { const rejected = vi.fn(async () => { throw Object.assign(new Error('Unprocessable'), { status: 422 }) }) clientLog.error('window', 'crash') setClientLogTransport({ enabled: () => true, send: rejected }) expect(await shipClientLog()).toBe(false) expect(await shipClientLog()).toBe(false) expect(rejected).toHaveBeenCalledTimes(1) resetClientLogForTests() const flaky = vi.fn(async () => { throw Object.assign(new Error('Server error'), { status: 500 }) }) clientLog.error('window', 'crash again') setClientLogTransport({ enabled: () => true, send: flaky }) expect(await shipClientLog()).toBe(false) expect(await shipClientLog()).toBe(false) expect(flaky).toHaveBeenCalledTimes(2) expect(logEntries.value[0].shipped).toBe(0) }) it('restores unshipped entries from IndexedDB after a reload', async () => { clientLog.error('window', 'Absturz vor dem Versand') await waitFor(() => false, { rounds: 3 }).catch(() => {}) await flush(2) expect(await db.clientLogs.count()).toBe(1) // A reload: memory is gone, the table is not. resetClientLogForTests() expect(logEntries.value).toHaveLength(0) await restoreClientLog() expect(logEntries.value).toHaveLength(1) expect(logEntries.value[0]).toMatchObject({ message: 'Absturz vor dem Versand', shipped: 0 }) }) })