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>
239 lines
7.4 KiB
JavaScript
239 lines
7.4 KiB
JavaScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { createPinia, setActivePinia } from 'pinia'
|
|
import { flush } from './helpers'
|
|
|
|
// This suite runs against the *real* apiClient — the 401 handling and the
|
|
// token revoke are exactly what is under test — so only `fetch` is faked.
|
|
|
|
function jsonResponse(status, body = null) {
|
|
return {
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
text: async () => (body === null ? '' : JSON.stringify(body))
|
|
}
|
|
}
|
|
|
|
describe('session handling — an expired token ends the session (A11)', () => {
|
|
let apiClient
|
|
let useAuthStore
|
|
let fetchMock
|
|
|
|
beforeEach(async () => {
|
|
setActivePinia(createPinia())
|
|
vi.resetModules()
|
|
localStorage.clear()
|
|
|
|
fetchMock = vi.fn()
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
apiClient = await import('src/services/apiClient')
|
|
useAuthStore = (await import('stores/auth')).useAuthStore
|
|
|
|
const { db } = await import('src/db')
|
|
await db.meta.clear()
|
|
})
|
|
|
|
it('clears token, profile and stored auth when the API answers 401', async () => {
|
|
const authStore = useAuthStore()
|
|
|
|
await apiClient.setToken('token-a')
|
|
authStore.currentUserId = 'remote-user-1'
|
|
localStorage.setItem('thatsme-auth', JSON.stringify({ userId: 'remote-user-1' }))
|
|
|
|
fetchMock.mockResolvedValueOnce(jsonResponse(401, { message: 'Unauthenticated.' }))
|
|
await apiClient.apiFetch('/events')
|
|
await flush(2)
|
|
|
|
expect(await apiClient.getToken()).toBeNull()
|
|
expect(authStore.currentUserId).toBeNull()
|
|
expect(authStore.isAuthenticated).toBe(false)
|
|
// The router guard reads this key; leaving it behind sent the user
|
|
// straight back into an app that could no longer save.
|
|
expect(localStorage.getItem('thatsme-auth')).toBeNull()
|
|
expect(authStore.lastError).toBeTruthy()
|
|
})
|
|
|
|
it('ends the session when no token is stored any more (logged out in another tab)', async () => {
|
|
const authStore = useAuthStore()
|
|
|
|
// Remote user restored from localStorage, but the shared token is gone.
|
|
authStore.currentUserId = 'remote-user-1'
|
|
localStorage.setItem('thatsme-auth', JSON.stringify({ userId: 'remote-user-1' }))
|
|
|
|
await expect(apiClient.apiFetch('/events')).rejects.toMatchObject({ status: 401 })
|
|
await flush(2)
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled()
|
|
expect(authStore.currentUserId).toBeNull()
|
|
expect(localStorage.getItem('thatsme-auth')).toBeNull()
|
|
expect(authStore.lastError).toBeTruthy()
|
|
})
|
|
|
|
it('does not tear down a newer session because of a late 401 from the old one', async () => {
|
|
const authStore = useAuthStore()
|
|
|
|
await apiClient.setToken('token-old')
|
|
|
|
// The old session's request is answered late; by then a new session has
|
|
// already stored its own token.
|
|
let resolveRequest
|
|
fetchMock.mockImplementationOnce(() => new Promise((resolve) => { resolveRequest = resolve }))
|
|
const pending = apiClient.apiFetch('/events')
|
|
await flush(1)
|
|
|
|
await apiClient.setToken('token-new')
|
|
authStore.currentUserId = 'remote-user-2'
|
|
localStorage.setItem('thatsme-auth', JSON.stringify({ userId: 'remote-user-2' }))
|
|
|
|
resolveRequest(jsonResponse(401, { message: 'Unauthenticated.' }))
|
|
await pending
|
|
await flush(2)
|
|
|
|
expect(await apiClient.getToken()).toBe('token-new')
|
|
expect(authStore.currentUserId).toBe('remote-user-2')
|
|
expect(localStorage.getItem('thatsme-auth')).not.toBeNull()
|
|
})
|
|
|
|
it('leaves a local demo session alone when a stray 401 arrives', async () => {
|
|
const authStore = useAuthStore()
|
|
|
|
await apiClient.setToken('token-a')
|
|
authStore.currentUserId = 'local-demo'
|
|
localStorage.setItem('thatsme-auth', JSON.stringify({ userId: 'local-demo' }))
|
|
|
|
fetchMock.mockResolvedValueOnce(jsonResponse(401))
|
|
await apiClient.apiFetch('/events')
|
|
await flush(2)
|
|
|
|
expect(authStore.currentUserId).toBe('local-demo')
|
|
})
|
|
})
|
|
|
|
describe('session handling — logout (A11)', () => {
|
|
let apiClient
|
|
let useAuthStore
|
|
let fetchMock
|
|
|
|
beforeEach(async () => {
|
|
setActivePinia(createPinia())
|
|
vi.resetModules()
|
|
localStorage.clear()
|
|
|
|
fetchMock = vi.fn().mockResolvedValue(jsonResponse(204))
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
apiClient = await import('src/services/apiClient')
|
|
useAuthStore = (await import('stores/auth')).useAuthStore
|
|
|
|
const { db } = await import('src/db')
|
|
await db.meta.clear()
|
|
})
|
|
|
|
it('clears the local session synchronously so the router guard sees it', async () => {
|
|
const authStore = useAuthStore()
|
|
await apiClient.setToken('token-a')
|
|
authStore.currentUserId = 'remote-user-1'
|
|
localStorage.setItem('thatsme-auth', JSON.stringify({ userId: 'remote-user-1' }))
|
|
|
|
const done = authStore.logout()
|
|
|
|
// No await yet: the caller navigates to /login right after this line.
|
|
expect(authStore.currentUserId).toBeNull()
|
|
expect(localStorage.getItem('thatsme-auth')).toBeNull()
|
|
|
|
await done
|
|
})
|
|
|
|
it('revokes the token that is being discarded', async () => {
|
|
const authStore = useAuthStore()
|
|
await apiClient.setToken('token-a')
|
|
authStore.currentUserId = 'remote-user-1'
|
|
|
|
await authStore.logout()
|
|
await flush(2)
|
|
|
|
const revoke = fetchMock.mock.calls.find(([url]) => String(url).endsWith('/logout'))
|
|
expect(revoke).toBeDefined()
|
|
expect(revoke[1].headers.Authorization).toBe('Bearer token-a')
|
|
expect(await apiClient.getToken()).toBeNull()
|
|
})
|
|
|
|
it('still ends the session when the revoke request fails', async () => {
|
|
const authStore = useAuthStore()
|
|
await apiClient.setToken('token-a')
|
|
authStore.currentUserId = 'remote-user-1'
|
|
|
|
fetchMock.mockRejectedValue(new Error('offline'))
|
|
|
|
await authStore.logout()
|
|
await flush(2)
|
|
|
|
expect(authStore.currentUserId).toBeNull()
|
|
expect(await apiClient.getToken()).toBeNull()
|
|
})
|
|
|
|
it('does not call the API when a local demo user logs out', async () => {
|
|
const authStore = useAuthStore()
|
|
authStore.currentUserId = 'local-demo'
|
|
|
|
await authStore.logout()
|
|
await flush(2)
|
|
|
|
expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/logout'))).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('image cache is scoped per user (A10)', () => {
|
|
let useImageCache
|
|
let db
|
|
|
|
beforeEach(async () => {
|
|
setActivePinia(createPinia())
|
|
vi.resetModules()
|
|
vi.stubGlobal('fetch', vi.fn())
|
|
|
|
useImageCache = await import('composables/useImageCache')
|
|
db = (await import('src/db')).db
|
|
await db.scopedImageCache.clear()
|
|
})
|
|
|
|
it('drops the previous user\'s cached images when the scope changes', async () => {
|
|
useImageCache.setImageCacheScope('remote-user-1')
|
|
|
|
await db.scopedImageCache.put({
|
|
key: 'http://api.test|remote-user-1|/event-media/1/thumb',
|
|
url: '/event-media/1/thumb',
|
|
userId: 'remote-user-1',
|
|
eventId: 'e1',
|
|
type: 'thumbnail',
|
|
blob: new Blob(['x']),
|
|
cachedAt: Date.now()
|
|
})
|
|
|
|
expect(await db.scopedImageCache.count()).toBe(1)
|
|
|
|
useImageCache.setImageCacheScope('remote-user-2')
|
|
await flush(2)
|
|
|
|
expect(await db.scopedImageCache.count()).toBe(0)
|
|
})
|
|
|
|
it('clears the cache on logout', async () => {
|
|
useImageCache.setImageCacheScope('remote-user-1')
|
|
|
|
await db.scopedImageCache.put({
|
|
key: 'http://api.test|remote-user-1|/event-media/2/thumb',
|
|
url: '/event-media/2/thumb',
|
|
userId: 'remote-user-1',
|
|
eventId: 'e2',
|
|
type: 'thumbnail',
|
|
blob: new Blob(['x']),
|
|
cachedAt: Date.now()
|
|
})
|
|
|
|
useImageCache.setImageCacheScope(null)
|
|
await flush(2)
|
|
|
|
expect(await db.scopedImageCache.count()).toBe(0)
|
|
})
|
|
})
|