thats-me/frontend/test/vitest/auth-session.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

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)
})
})