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>
307 lines
12 KiB
JavaScript
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')
|
|
})
|
|
})
|