Added tests, shift 'delete'. Fixed overnight shifts, sync, error handling.
This commit is contained in:
parent
9d0fa1f0af
commit
f9c4facad6
21 changed files with 2522 additions and 40 deletions
945
frontend/package-lock.json
generated
945
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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
98
frontend/src/api.test.js
Normal 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: 'Titania' })
|
||||
const result = await apiJSON('/api/test')
|
||||
expect(result.name).toBe('Titania')
|
||||
})
|
||||
|
||||
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
49
frontend/src/db.test.js
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
88
frontend/src/sync.test.js
Normal 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: 'Titania' }],
|
||||
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('Titania')
|
||||
expect(await getLastSync()).toBe('2026-03-01T12:00:00Z')
|
||||
})
|
||||
|
||||
it('deletes soft-deleted attendees from Dexie', async () => {
|
||||
await db.attendees.put({ id: 1, name: 'Titania' })
|
||||
|
||||
mockFetch({
|
||||
server_time: '2026-03-01T13:00:00Z',
|
||||
attendees: [{ id: 1, name: 'Titania', 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')
|
||||
})
|
||||
})
|
||||
1
frontend/src/test-setup.js
Normal file
1
frontend/src/test-setup.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
import 'fake-indexeddb/auto'
|
||||
|
|
@ -12,4 +12,8 @@ export default defineConfig({
|
|||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test-setup.js'],
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue