Added tests, shift 'delete'. Fixed overnight shifts, sync, error handling.

This commit is contained in:
Pen Anderson 2026-03-03 12:50:24 -06:00
parent 49047b4745
commit 30679ff1a5
20 changed files with 2521 additions and 39 deletions

File diff suppressed because it is too large Load diff

View file

@ -6,12 +6,17 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"fake-indexeddb": "^6.2.5",
"jsdom": "^28.1.0",
"svelte": "^5.45.2",
"vite": "^7.3.1"
"vite": "^7.3.1",
"vitest": "^4.0.18"
},
"dependencies": {
"dexie": "^4.3.0"

98
frontend/src/api.test.js Normal file
View file

@ -0,0 +1,98 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { db, saveSession, clearSession } from './db.js'
// Must import api after fake-indexeddb is initialized (via test-setup.js)
const { apiFetch, apiJSON, api } = await import('./api.js')
beforeEach(async () => {
await Promise.all(db.tables.map(t => t.clear()))
vi.restoreAllMocks()
})
function mockFetch(body = {}, status = 200) {
const fn = vi.fn(() =>
Promise.resolve({
ok: status >= 200 && status < 300,
status,
statusText: 'OK',
json: () => Promise.resolve(body),
text: () => Promise.resolve(JSON.stringify(body)),
})
)
globalThis.fetch = fn
return fn
}
describe('apiFetch', () => {
it('adds Authorization header when session exists', async () => {
await saveSession('mytoken', { id: 1 })
const f = mockFetch()
await apiFetch('/api/test')
expect(f).toHaveBeenCalledTimes(1)
const [, opts] = f.mock.calls[0]
expect(opts.headers['Authorization']).toBe('Bearer mytoken')
})
it('omits Authorization when no session', async () => {
const f = mockFetch()
await apiFetch('/api/test')
const [, opts] = f.mock.calls[0]
expect(opts.headers['Authorization']).toBeUndefined()
})
it('clears session on 401', async () => {
await saveSession('expired', { id: 1 })
mockFetch({}, 401)
await expect(apiFetch('/api/test')).rejects.toThrow('unauthorized')
expect(await db.session.get(1)).toBeUndefined()
})
})
describe('apiJSON', () => {
it('parses JSON response', async () => {
mockFetch({ name: 'Alice' })
const result = await apiJSON('/api/test')
expect(result.name).toBe('Alice')
})
it('throws on non-OK response', async () => {
mockFetch({ error: 'not found' }, 404)
await expect(apiJSON('/api/test')).rejects.toThrow('not found')
})
})
describe('api methods', () => {
it('login calls correct endpoint', async () => {
const f = mockFetch({ token: 'tok', user: { id: 1 } })
await api.login('admin', 'pass')
const [url, opts] = f.mock.calls[0]
expect(url).toBe('/api/login')
expect(opts.method).toBe('POST')
expect(JSON.parse(opts.body)).toEqual({ username: 'admin', password: 'pass' })
})
it('attendees.list calls correct endpoint', async () => {
const f = mockFetch({ attendees: [] })
await api.attendees.list({ search: 'test' })
expect(f.mock.calls[0][0]).toBe('/api/attendees?search=test')
})
it('attendees.delete uses DELETE method', async () => {
const f = mockFetch({}, 204)
await api.attendees.delete(5)
expect(f.mock.calls[0][0]).toBe('/api/attendees/5')
expect(f.mock.calls[0][1].method).toBe('DELETE')
})
it('sync.pull passes since param', async () => {
const f = mockFetch({ server_time: '2026-01-01', attendees: [] })
await api.sync.pull('2026-01-01T00:00:00Z')
expect(f.mock.calls[0][0]).toContain('since=')
})
it('sync.pull omits since when empty', async () => {
const f = mockFetch({ server_time: '2026-01-01', attendees: [] })
await api.sync.pull('')
expect(f.mock.calls[0][0]).toBe('/api/sync/pull')
})
})

49
frontend/src/db.test.js Normal file
View file

@ -0,0 +1,49 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { db, getLastSync, setLastSync, getSession, saveSession, clearSession } from './db.js'
beforeEach(async () => {
await Promise.all(db.tables.map(t => t.clear()))
})
describe('db schema', () => {
it('has expected tables', () => {
const names = db.tables.map(t => t.name).sort()
expect(names).toEqual([
'attendees', 'departments', 'event', 'meta',
'outbox', 'session', 'shifts', 'volunteer_shifts', 'volunteers',
])
})
})
describe('session', () => {
it('returns undefined when no session', async () => {
const s = await getSession()
expect(s).toBeUndefined()
})
it('saves and retrieves session', async () => {
await saveSession('tok123', { id: 1, username: 'admin', role: 'admin' })
const s = await getSession()
expect(s.token).toBe('tok123')
expect(s.user.username).toBe('admin')
})
it('clears session and meta', async () => {
await saveSession('tok123', { id: 1 })
await setLastSync('2026-01-01T00:00:00Z')
await clearSession()
expect(await getSession()).toBeUndefined()
expect(await getLastSync()).toBe('')
})
})
describe('lastSync', () => {
it('returns empty string when unset', async () => {
expect(await getLastSync()).toBe('')
})
it('roundtrips a timestamp', async () => {
await setLastSync('2026-03-01T12:00:00Z')
expect(await getLastSync()).toBe('2026-03-01T12:00:00Z')
})
})

View file

@ -40,6 +40,9 @@ export async function syncPull() {
}
if (data.volunteer_shifts?.length) {
await db.volunteer_shifts.bulkPut(data.volunteer_shifts)
const deleted = data.volunteer_shifts.filter(vs => vs.deleted_at)
.map(vs => [vs.volunteer_id, vs.shift_id])
if (deleted.length) await db.volunteer_shifts.bulkDelete(deleted)
}
}
)
@ -65,27 +68,30 @@ export function startSSE(onEvent) {
sseSource = new EventSource(`/api/sync/stream?token=${encodeURIComponent(session.token)}`)
sseSource.onmessage = (e) => {
sseSource.onmessage = async (e) => {
try {
const payload = JSON.parse(e.data)
if (payload.event === 'checkin') {
// Apply check-in to local Dexie immediately
if (payload.data?.type === 'attendee' && payload.data?.attendee) {
db.attendees.put(payload.data.attendee)
await db.attendees.put(payload.data.attendee)
}
if (payload.data?.type === 'volunteer' && payload.data?.volunteer) {
db.volunteers.put(payload.data.volunteer)
await db.volunteers.put(payload.data.volunteer)
}
onEvent?.(payload)
}
} catch {}
} catch (err) {
console.warn('SSE message error:', err.message)
}
}
sseSource.onerror = () => {
sseSource?.close()
sseSource = null
// Reconnect after 5s
setTimeout(connect, 5000)
setTimeout(() => {
connect()
syncPull()
}, 5000)
}
})
}

88
frontend/src/sync.test.js Normal file
View file

@ -0,0 +1,88 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { db, getLastSync, setLastSync } from './db.js'
beforeEach(async () => {
await Promise.all(db.tables.map(t => t.clear()))
vi.restoreAllMocks()
})
function mockFetch(body = {}, status = 200) {
globalThis.fetch = vi.fn(() =>
Promise.resolve({
ok: status >= 200 && status < 300,
status,
statusText: 'OK',
json: () => Promise.resolve(body),
})
)
}
describe('syncPull', () => {
it('writes attendees to Dexie', async () => {
mockFetch({
server_time: '2026-03-01T12:00:00Z',
attendees: [{ id: 1, name: 'Alice' }],
departments: [],
volunteers: [],
shifts: [],
volunteer_shifts: [],
})
// Import fresh to reset syncing guard
const { syncPull } = await import('./sync.js')
await syncPull()
const a = await db.attendees.get(1)
expect(a.name).toBe('Alice')
expect(await getLastSync()).toBe('2026-03-01T12:00:00Z')
})
it('deletes soft-deleted attendees from Dexie', async () => {
await db.attendees.put({ id: 1, name: 'Alice' })
mockFetch({
server_time: '2026-03-01T13:00:00Z',
attendees: [{ id: 1, name: 'Alice', deleted_at: '2026-03-01T12:30:00Z' }],
departments: [],
volunteers: [],
shifts: [],
volunteer_shifts: [],
})
const { syncPull } = await import('./sync.js')
await syncPull()
const a = await db.attendees.get(1)
expect(a).toBeUndefined()
})
it('deletes soft-deleted volunteer_shifts from Dexie', async () => {
await db.volunteer_shifts.put({ volunteer_id: 1, shift_id: 2 })
mockFetch({
server_time: '2026-03-01T13:00:00Z',
attendees: [],
departments: [],
volunteers: [],
shifts: [],
volunteer_shifts: [{ volunteer_id: 1, shift_id: 2, deleted_at: '2026-03-01T12:30:00Z' }],
})
const { syncPull } = await import('./sync.js')
await syncPull()
const vs = await db.volunteer_shifts.get([1, 2])
expect(vs).toBeUndefined()
})
it('sets lastSync timestamp', async () => {
mockFetch({
server_time: '2026-03-02T00:00:00Z',
attendees: [],
departments: [],
volunteers: [],
shifts: [],
volunteer_shifts: [],
})
const { syncPull } = await import('./sync.js')
await syncPull()
expect(await getLastSync()).toBe('2026-03-02T00:00:00Z')
})
})

View file

@ -0,0 +1 @@
import 'fake-indexeddb/auto'

View file

@ -12,4 +12,8 @@ export default defineConfig({
outDir: 'dist',
emptyOutDir: true,
},
test: {
environment: 'jsdom',
setupFiles: ['./src/test-setup.js'],
},
})