thats-me/frontend/test/vitest/events-offline.spec.js
Kevin Adametz 83c6c5813d Frontend: Offline-Warteschlange, PWA, Event-Panel und Markenauftritt
Offline
- Alle Schreibzugriffe von API-Nutzern laufen über eine Warteschlange in
  IndexedDB (services/syncQueue.js): FIFO, zusammengefasste Updates,
  Wiederholung bei Netz/Sichtbarkeit/Backoff, 4xx wird als Fehler gemeldet.
- Medien-Uploads liegen mit Bytes in der Queue, erscheinen sofort mit
  Marker und werden nach dem Upload durch das Server-Objekt ersetzt.
- PWA-Modus mit Service Worker für die App-Hülle; API nie gecacht.
  npm run build erzeugt dist/pwa.
- Speicherzustand im Panel: ausstehend / Fehler mit Wiederholen-Knopf,
  Verbindungsanzeige im Header.

Event
- Event entsteht erst mit dem ersten Eintrag; jede Löschung fragt nach.
- Key Image als 4:3-Rahmen, Galerie mit Video, mehrere Sprachnotizen mit
  Notiz, Live-Pegel beim Aufnehmen, WAV-Umwandlung für lokale Aufnahmen.
- Neue Felder: Zeitraum, Kategorie, Personen, Meilenstein; Stimmung als
  kompakte Zeile mit ausklappbarer Punktfarbe.

Oberfläche
- Untere Leiste: Darstellung und Zoom links, Jahres-Pille mittig, Plus rechts.
- Logos und Goldpalette, Login neu, zehn Wellen-Presets mit Vorschau,
  Hintergrundbilder als Kacheln, Kontomenü und Einstellungen bereinigt,
  Escape schließt Ebenen, größere Touch-Ziele.
- Alten Sync-Service und ungenutzte Legacy-Seiten entfernt.

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

307 lines
12 KiB
JavaScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { createApiRouter, drainQueue, flush, page, remoteEvent, waitFor } from './helpers'
let api = createApiRouter()
vi.mock('src/services/apiClient', () => ({
API_BASE: 'http://api.test',
apiJson: (...args) => api.apiJson(...args),
apiFetch: vi.fn(),
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 EVENTS_LIST = /^\/events\?/
const EVENTS_CREATE = (url, options) => url === '/events' && options?.method === 'POST'
const EVENT_UPDATE = (url, options) => /^\/events\/[^/]+$/.test(url) && options?.method === 'PUT'
const EVENT_DELETE = (url, options) => /^\/events\/[^/]+$/.test(url) && options?.method === 'DELETE'
const MEDIA_UPLOAD = (url, options) => /^\/events\/[^/]+\/media$/.test(url) && options?.method === 'POST'
const MEDIA_PATCH = (url, options) => /^\/events\/[^/]+\/media\/[^/]+$/.test(url) && options?.method === 'PATCH'
const MEDIA_DELETE = (url, options) => /^\/events\/[^/]+\/media\/[^/]+$/.test(url) && options?.method === 'DELETE'
// What fetch throws when there is no network: an error without a status.
const networkDown = () => Promise.reject(new TypeError('Failed to fetch'))
// Past the 300 ms autosave debounce.
async function settle() {
await flush(2)
await new Promise(resolve => setTimeout(resolve, 400))
await flush(2)
}
/**
* A server that can be switched off. While down, every write fails like a
* lost connection; the reads keep working so the start-up load succeeds.
*/
function serverWithSwitch() {
const state = { down: false, uploads: 0 }
const guard = respond => (url, options) => (state.down ? networkDown() : respond(url, options))
api.always(EVENTS_CREATE, guard((url, options) => Promise.resolve({ data: remoteEvent({ ...(options.body ?? {}) }) })))
api.always(EVENT_UPDATE, guard((url, options) => Promise.resolve({
data: remoteEvent({ id: url.split('/').pop(), ...(options.body ?? {}) })
})))
api.always(EVENT_DELETE, guard(() => Promise.resolve(null)))
api.always(MEDIA_UPLOAD, guard((url, options) => Promise.resolve({
data: {
id: `up${++state.uploads}`,
uuid: `uuid-${state.uploads}`,
type: options.body.get('collection') === 'voice_note' ? 'audio' : 'image',
collection: options.body.get('collection'),
name: options.body.get('file')?.name,
caption: options.body.get('caption') ?? '',
mimeType: 'audio/mp4',
durationMs: 1500,
src: null,
thumbnailUrl: null,
previewUrl: null,
originalUrl: `/event-media/up${state.uploads}/original`,
createdAt: 5
}
})))
api.always(MEDIA_PATCH, guard((url, options) => Promise.resolve({
data: { id: url.split('/').pop(), caption: options.body.caption }
})))
api.always(MEDIA_DELETE, guard(() => Promise.resolve(null)))
return state
}
describe('events store — changes made without a connection are kept and sent later', () => {
let eventsStore
let server
let db
beforeEach(async () => {
setActivePinia(createPinia())
api = createApiRouter()
vi.resetModules()
db = (await import('src/db')).db
await db.events.clear()
await db.eventMedia.clear()
await db.syncQueue.clear()
const { useEventsStore } = await import('stores/events')
const { useAuthStore } = await import('stores/auth')
api.once(EVENTS_LIST, page([remoteEvent({ id: 'e1', title: 'vorhanden', note: '' })]))
server = serverWithSwitch()
const authStore = useAuthStore()
eventsStore = useEventsStore()
authStore.currentUserId = 'remote-user-1'
await flush()
})
afterEach(async () => {
eventsStore.closePanel()
// A queue still sending when the test ends would hit the next test's
// API mock; let it finish first.
server.down = false
await drainQueue(eventsStore)
})
it('keeps an edit made while the server is unreachable and sends it once it is back', async () => {
server.down = true
eventsStore.openPanel('e1')
await flush(2)
eventsStore.ghostTitle = 'im Zug geändert'
await settle()
await waitFor(() => eventsStore.saveState === 'pending' && eventsStore.pendingSyncCount === 1)
// Stored locally, flagged as waiting, not as failed.
expect(eventsStore.saveState).toBe('pending')
expect(eventsStore.lastError).toBeNull()
expect(eventsStore.pendingSyncCount).toBe(1)
expect(eventsStore.events.find(event => event.id === 'e1').title).toBe('im Zug geändert')
expect((await db.events.get('e1')).title).toBe('im Zug geändert')
expect(api.callsTo(EVENT_UPDATE)).toHaveLength(1)
server.down = false
await eventsStore.syncNow()
await waitFor(() => eventsStore.pendingSyncCount === 0 && eventsStore.saveState === 'saved')
expect(api.callsTo(EVENT_UPDATE)).toHaveLength(2)
expect(api.callsTo(EVENT_UPDATE)[1].options.body.title).toBe('im Zug geändert')
expect(eventsStore.pendingSyncCount).toBe(0)
expect(eventsStore.saveState).toBe('saved')
expect(eventsStore.events.find(event => event.id === 'e1').syncStatus).toBe('synced')
})
it('collapses several offline edits of one event into a single request', async () => {
server.down = true
eventsStore.openPanel('e1')
await flush(2)
eventsStore.ghostTitle = 'eins'
await settle()
eventsStore.ghostTitle = 'zwei'
await settle()
eventsStore.ghostNote = 'und eine Notiz'
await settle()
await waitFor(() => eventsStore.pendingSyncCount === 1)
server.down = false
await eventsStore.syncNow()
await waitFor(() => eventsStore.pendingSyncCount === 0)
const sent = api.callsTo(EVENT_UPDATE).filter(call => !call.failed)
const last = sent[sent.length - 1].options.body
expect(last.title).toBe('zwei')
expect(last.note).toBe('und eine Notiz')
expect(eventsStore.pendingSyncCount).toBe(0)
})
it('never sends an event that was created and deleted again while offline', async () => {
server.down = true
eventsStore.openPanel()
await flush(2)
eventsStore.ghostTitle = 'kurzlebig'
await settle()
const id = eventsStore.editingEventId
expect(id).not.toBeNull()
await waitFor(() => eventsStore.pendingSyncCount === 1 && api.callsTo(EVENTS_CREATE).length === 1)
eventsStore.deleteEvent(id)
await waitFor(() => eventsStore.pendingSyncCount === 0)
expect(eventsStore.events.find(event => event.id === id)).toBeUndefined()
server.down = false
await eventsStore.syncNow()
await flush()
// The failed attempt while offline is the only one there ever was.
expect(api.callsTo(EVENTS_CREATE)).toHaveLength(1)
expect(api.callsTo(EVENT_DELETE)).toHaveLength(0)
})
it('keeps a recording made offline, uploads it later with the caption typed meanwhile', async () => {
server.down = true
eventsStore.openPanel('e1')
await flush(2)
const file = new File(['audio'], 'sprachnotiz.webm', { type: 'audio/webm' })
const note = await eventsStore.uploadGhostVoiceNote(file, { durationMs: 2100 })
await waitFor(() => eventsStore.pendingSyncCount === 1)
expect(note.pending).toBe(true)
expect(eventsStore.ghostVoiceNotes.map(entry => entry.id)).toEqual([note.id])
// The recording itself is in the queue, so it survives a reload.
const rows = await db.syncQueue.toArray()
expect(rows.map(row => row.action)).toEqual(['upload_media'])
expect(rows[0].payload.bytes).toBeInstanceOf(ArrayBuffer)
eventsStore.setGhostVoiceNoteCaption(note.id, 'ohne Netz beschriftet')
await new Promise(resolve => setTimeout(resolve, 700))
await flush()
// The caption went into the pending upload, not into a request of its own.
expect(api.callsTo(MEDIA_PATCH)).toHaveLength(0)
expect((await db.syncQueue.toArray())[0].payload.caption).toBe('ohne Netz beschriftet')
server.down = false
await eventsStore.syncNow()
await waitFor(() => eventsStore.pendingSyncCount === 0 && eventsStore.ghostVoiceNotes.some(entry => entry.id === 'up1'))
const uploads = api.callsTo(MEDIA_UPLOAD)
const sent = uploads[uploads.length - 1].options.body
expect(sent.get('collection')).toBe('voice_note')
expect(sent.get('caption')).toBe('ohne Netz beschriftet')
expect(sent.get('file')?.name).toBe('sprachnotiz.webm')
// The local item became the server's item, caption included.
expect(eventsStore.ghostVoiceNotes.map(entry => entry.id)).toEqual(['up1'])
expect(eventsStore.ghostVoiceNotes[0].caption).toBe('ohne Netz beschriftet')
expect(eventsStore.pendingSyncCount).toBe(0)
expect(await db.eventMedia.get(note.id)).toBeUndefined()
expect((await db.eventMedia.get('up1'))?.eventId).toBe('e1')
})
it('drops a queued upload when the file is removed before it was sent', async () => {
server.down = true
eventsStore.openPanel('e1')
await flush(2)
const [item] = await eventsStore.uploadGhostMedia([{ file: new File(['img'], 'foto.jpg', { type: 'image/jpeg' }) }])
await waitFor(() => eventsStore.pendingSyncCount === 1 && api.callsTo(MEDIA_UPLOAD).length === 1)
await eventsStore.deleteGhostMedia(item.id)
await waitFor(() => eventsStore.pendingSyncCount === 0)
// Only the attempt that failed while the server was down was ever made.
const attemptsWhileDown = api.callsTo(MEDIA_UPLOAD).length
server.down = false
await eventsStore.syncNow()
await flush()
expect(api.callsTo(MEDIA_UPLOAD)).toHaveLength(attemptsWhileDown)
expect(api.callsTo(MEDIA_DELETE)).toHaveLength(0)
})
it('does not lose queued changes when the app starts with a working connection', async () => {
// Left behind by an earlier, offline session: the edit in the cache and
// its write in the queue.
await db.events.put({ ...remoteEvent({ id: 'e2', title: 'offline bearbeitet', note: 'lokal' }), userId: 'remote-user-2', syncStatus: 'pending' })
await db.syncQueue.add({
userId: 'remote-user-2',
eventId: 'e2',
action: 'update',
payload: { id: 'e2', title: 'offline bearbeitet', note: 'lokal', date: '2025-01-01', emotion: 0 },
createdAt: 1,
attempts: 1
})
setActivePinia(createPinia())
api = createApiRouter()
// The list reflects what the server holds at the time it is asked, so
// an update that arrives first shows up in it.
const serverCopy = remoteEvent({ id: 'e2', title: 'Serverstand', note: '' })
api.always(EVENT_UPDATE, (url, options) => {
Object.assign(serverCopy, options.body ?? {})
return Promise.resolve({ data: { ...serverCopy } })
})
api.once(EVENTS_LIST, () => Promise.resolve(page([{ ...serverCopy }])))
const { useEventsStore } = await import('stores/events')
const { useAuthStore } = await import('stores/auth')
const authStore = useAuthStore()
const store = useEventsStore()
authStore.currentUserId = 'remote-user-2'
// isLoaded is already true from the userless start; wait for the load
// that belongs to this user.
await waitFor(() => api.callsTo(EVENTS_LIST).length === 1 && store.isLoaded && store.pendingSyncCount === 0)
// The queue went out before the list was read, so the server already
// knows the edit and the timeline shows it.
expect(api.callsTo(EVENT_UPDATE)).toHaveLength(1)
expect(api.callsTo(EVENT_UPDATE)[0].options.body.title).toBe('offline bearbeitet')
expect(store.pendingSyncCount).toBe(0)
expect(store.events).toHaveLength(1)
expect(store.events[0].title).toBe('offline bearbeitet')
})
it('a rejected edit is reported as an error and leaves the queue', async () => {
api = createApiRouter()
api.always(EVENT_UPDATE, () => Promise.reject(Object.assign(new Error('unprocessable'), { status: 422 })))
eventsStore.openPanel('e1')
await flush(2)
eventsStore.ghostTitle = 'abgelehnt'
await settle()
expect(eventsStore.saveState).toBe('error')
expect(eventsStore.lastError).toBeTruthy()
expect(eventsStore.pendingSyncCount).toBe(0)
expect(eventsStore.events.find(event => event.id === 'e1').syncStatus).toBe('error')
})
})