diff --git a/Makefile b/Makefile index 72a39be..43067d4 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build frontend-build dev clean +.PHONY: build frontend-build dev clean test build: frontend-build CGO_ENABLED=0 go build -o turnpike . @@ -11,6 +11,10 @@ dev: @echo " Terminal 1: go run . --db dev.db" @echo " Terminal 2: cd frontend && npm run dev" +test: + go test ./... + cd frontend && npx vitest run + clean: rm -f turnpike dev.db rm -rf frontend/dist diff --git a/auth_test.go b/auth_test.go new file mode 100644 index 0000000..1a16571 --- /dev/null +++ b/auth_test.go @@ -0,0 +1,127 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestLoginValid(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + mux := testMux(app) + + req := testRequest("POST", "/api/login", map[string]string{ + "username": admin.Username, + "password": "admin123", + }) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200\nbody: %s", w.Code, w.Body.String()) + } + result := parseJSON(t, w) + if result["token"] == nil || result["token"] == "" { + t.Error("missing token in response") + } + user, ok := result["user"].(map[string]any) + if !ok || user["username"] != "admin" { + t.Errorf("user = %v", result["user"]) + } +} + +func TestLoginWrongPassword(t *testing.T) { + app := testApp(t) + testAdminUser(t, app) + mux := testMux(app) + + req := testRequest("POST", "/api/login", map[string]string{ + "username": "admin", + "password": "wrong", + }) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", w.Code) + } +} + +func TestLoginNonexistentUser(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + req := testRequest("POST", "/api/login", map[string]string{ + "username": "nobody", + "password": "test", + }) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", w.Code) + } +} + +func TestAuthMiddlewareNoToken(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + req := testRequest("GET", "/api/me", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", w.Code) + } +} + +func TestAuthMiddlewareInvalidToken(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + req := testAuthRequest("GET", "/api/me", nil, "bad-token") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", w.Code) + } +} + +func TestAuthMiddlewareRoleEnforcement(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + // Create a gate user — should not be able to access /api/users (admin only) + gate := testUserWithRole(t, app, "gateuser", "gate", []int{}) + token := testToken(t, app, gate) + + req := testAuthRequest("GET", "/api/users", nil, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403", w.Code) + } +} + +func TestMeEndpoint(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + req := testAuthRequest("GET", "/api/me", nil, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + result := parseJSON(t, w) + if result["username"] != "admin" { + t.Errorf("username = %v", result["username"]) + } +} diff --git a/db.go b/db.go index 2d7b8d2..ef3a339 100644 --- a/db.go +++ b/db.go @@ -134,6 +134,7 @@ func migrateV2(db *sql.DB) error { addColumnIfMissing(db, "attendees", "party_size INTEGER NOT NULL DEFAULT 1") addColumnIfMissing(db, "attendees", "checked_in_count INTEGER NOT NULL DEFAULT 0") addColumnIfMissing(db, "shifts", "position INTEGER NOT NULL DEFAULT 0") + addColumnIfMissing(db, "volunteer_shifts", "deleted_at TEXT") // Widen the uniqueness constraint from name-only to (name, ticket_id). db.Exec(`DROP INDEX IF EXISTS idx_attendees_name`) db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_attendees_name_ticket ON attendees(name, ticket_id) WHERE deleted_at IS NULL`) @@ -245,10 +246,11 @@ type Shift struct { } type VolunteerShift struct { - VolunteerID int `json:"volunteer_id"` - ShiftID int `json:"shift_id"` - Confirmed bool `json:"confirmed"` - UpdatedAt string `json:"updated_at"` + VolunteerID int `json:"volunteer_id"` + ShiftID int `json:"shift_id"` + Confirmed bool `json:"confirmed"` + UpdatedAt string `json:"updated_at"` + DeletedAt *string `json:"deleted_at"` } // --- Event --- @@ -413,21 +415,28 @@ func (app *App) countUsers() (int, error) { const tokenChars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" -func generateToken() string { +func generateToken() (string, error) { b := make([]byte, 8) - rand.Read(b) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("read random: %w", err) + } result := make([]byte, 8) for i, v := range b { result[i] = tokenChars[int(v)%len(tokenChars)] } - return string(result) + return string(result), nil } func (app *App) generateUniqueToken() (string, error) { for range 10 { - t := generateToken() + t, err := generateToken() + if err != nil { + return "", err + } var count int - app.db.QueryRow(`SELECT COUNT(*) FROM attendees WHERE volunteer_token = ?`, t).Scan(&count) + if err := app.db.QueryRow(`SELECT COUNT(*) FROM attendees WHERE volunteer_token = ?`, t).Scan(&count); err != nil { + return "", fmt.Errorf("check token uniqueness: %w", err) + } if count == 0 { return t, nil } @@ -452,13 +461,15 @@ func (app *App) generateTokensForAll() (int, error) { if err != nil { return 0, err } + defer rows.Close() var ids []int for rows.Next() { var id int - rows.Scan(&id) + if err := rows.Scan(&id); err != nil { + return 0, fmt.Errorf("scan attendee id: %w", err) + } ids = append(ids, id) } - rows.Close() count := 0 for _, id := range ids { @@ -912,7 +923,7 @@ func queryShifts(db *sql.DB, q string, args ...any) ([]Shift, error) { // shiftAssignedCount returns the number of volunteers currently assigned to a shift. func (app *App) shiftAssignedCount(shiftID int) (int, error) { var count int - err := app.db.QueryRow(`SELECT COUNT(*) FROM volunteer_shifts WHERE shift_id = ?`, shiftID).Scan(&count) + err := app.db.QueryRow(`SELECT COUNT(*) FROM volunteer_shifts WHERE shift_id = ? AND deleted_at IS NULL`, shiftID).Scan(&count) return count, err } @@ -927,21 +938,40 @@ func (app *App) checkShiftConflict(volunteerID, shiftID int) ([]Shift, error) { SELECT `+shiftColsS+` FROM shifts s JOIN volunteer_shifts vs ON vs.shift_id = s.id - WHERE vs.volunteer_id = ? AND s.day = ? AND s.id != ? AND s.deleted_at IS NULL`, + WHERE vs.volunteer_id = ? AND vs.deleted_at IS NULL AND s.day = ? AND s.id != ? AND s.deleted_at IS NULL`, volunteerID, target.Day, shiftID) if err != nil { return nil, err } var conflicts []Shift for _, s := range existing { - // Overlap: one starts before the other ends (HH:MM string comparison works for same-day) - if s.StartTime < target.EndTime && target.StartTime < s.EndTime { + if timesOverlap(s.StartTime, s.EndTime, target.StartTime, target.EndTime) { conflicts = append(conflicts, s) } } return conflicts, nil } +// timesOverlap checks whether two time ranges (HH:MM) overlap, +// correctly handling ranges that span midnight (e.g. 22:00-02:00). +func timesOverlap(startA, endA, startB, endB string) bool { + // A shift spans midnight when its end time is <= its start time. + spansMidnightA := endA <= startA + spansMidnightB := endB <= startB + + switch { + case !spansMidnightA && !spansMidnightB: + return startA < endB && startB < endA + case spansMidnightA && !spansMidnightB: + return startB < endA || startB >= startA + case !spansMidnightA && spansMidnightB: + return startA < endB || startA >= startB + default: + // Both span midnight — they always overlap + return true + } +} + // reorderShifts updates the position field for each given shift. func (app *App) reorderShifts(positions []struct{ ID, Position int }) error { for _, p := range positions { @@ -959,15 +989,48 @@ func (app *App) reorderShifts(positions []struct{ ID, Position int }) error { func (app *App) assignShift(volunteerID, shiftID int) error { _, err := app.db.Exec( `INSERT INTO volunteer_shifts (volunteer_id, shift_id, updated_at) VALUES (?, ?, ?) - ON CONFLICT(volunteer_id, shift_id) DO UPDATE SET confirmed=1, updated_at=excluded.updated_at`, + ON CONFLICT(volunteer_id, shift_id) DO UPDATE SET confirmed=1, deleted_at=NULL, updated_at=excluded.updated_at`, volunteerID, shiftID, now(), ) return err } +// assignShiftWithCapacity atomically checks capacity and assigns. +// Returns errShiftFull if the shift is at capacity. +func (app *App) assignShiftWithCapacity(volunteerID, shiftID, capacity int) error { + tx, err := app.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + if capacity > 0 { + var count int + if err := tx.QueryRow(`SELECT COUNT(*) FROM volunteer_shifts WHERE shift_id = ? AND deleted_at IS NULL`, shiftID).Scan(&count); err != nil { + return err + } + if count >= capacity { + return errShiftFull + } + } + + if _, err := tx.Exec( + `INSERT INTO volunteer_shifts (volunteer_id, shift_id, updated_at) VALUES (?, ?, ?) + ON CONFLICT(volunteer_id, shift_id) DO UPDATE SET confirmed=1, deleted_at=NULL, updated_at=excluded.updated_at`, + volunteerID, shiftID, now(), + ); err != nil { + return err + } + + return tx.Commit() +} + +var errShiftFull = fmt.Errorf("shift is full") + func (app *App) unassignShift(volunteerID, shiftID int) error { _, err := app.db.Exec( - `DELETE FROM volunteer_shifts WHERE volunteer_id=? AND shift_id=?`, volunteerID, shiftID, + `UPDATE volunteer_shifts SET deleted_at = ?, updated_at = ? WHERE volunteer_id=? AND shift_id=?`, + now(), now(), volunteerID, shiftID, ) return err } @@ -976,10 +1039,10 @@ func (app *App) listVolunteerShifts(since string) ([]VolunteerShift, error) { var q string var args []any if since != "" { - q = `SELECT volunteer_id, shift_id, confirmed, updated_at FROM volunteer_shifts WHERE updated_at > ?` + q = `SELECT volunteer_id, shift_id, confirmed, updated_at, deleted_at FROM volunteer_shifts WHERE updated_at > ?` args = append(args, since) } else { - q = `SELECT volunteer_id, shift_id, confirmed, updated_at FROM volunteer_shifts` + q = `SELECT volunteer_id, shift_id, confirmed, updated_at, deleted_at FROM volunteer_shifts WHERE deleted_at IS NULL` } rows, err := app.db.Query(q, args...) if err != nil { @@ -990,7 +1053,7 @@ func (app *App) listVolunteerShifts(since string) ([]VolunteerShift, error) { for rows.Next() { var vs VolunteerShift var confirmed int - rows.Scan(&vs.VolunteerID, &vs.ShiftID, &confirmed, &vs.UpdatedAt) + rows.Scan(&vs.VolunteerID, &vs.ShiftID, &confirmed, &vs.UpdatedAt, &vs.DeletedAt) vs.Confirmed = confirmed == 1 result = append(result, vs) } @@ -1003,7 +1066,7 @@ func (app *App) listShiftsForVolunteer(volunteerID int) ([]Shift, error) { SELECT `+shiftColsS+` FROM shifts s JOIN volunteer_shifts vs ON vs.shift_id = s.id - WHERE vs.volunteer_id = ? AND s.deleted_at IS NULL + WHERE vs.volunteer_id = ? AND vs.deleted_at IS NULL AND s.deleted_at IS NULL ORDER BY s.day, s.position, s.start_time`, volunteerID) } @@ -1014,7 +1077,7 @@ func (app *App) listOpenShiftsForDept(deptID int) ([]Shift, error) { FROM shifts s WHERE s.department_id = ? AND s.deleted_at IS NULL AND (s.capacity = 0 OR ( - SELECT COUNT(*) FROM volunteer_shifts vs WHERE vs.shift_id = s.id + SELECT COUNT(*) FROM volunteer_shifts vs WHERE vs.shift_id = s.id AND vs.deleted_at IS NULL ) < s.capacity) ORDER BY s.day, s.position, s.start_time`, deptID) } diff --git a/db_test.go b/db_test.go new file mode 100644 index 0000000..1a4df38 --- /dev/null +++ b/db_test.go @@ -0,0 +1,275 @@ +package main + +import ( + "testing" +) + +func TestMigrate(t *testing.T) { + app := testApp(t) + // Verify tables exist by querying each one + tables := []string{"event", "users", "attendees", "departments", "volunteers", "shifts", "volunteer_shifts"} + for _, table := range tables { + var count int + err := app.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) + if err != nil { + t.Errorf("table %s: %v", table, err) + } + } +} + +func TestAttendeesCRUD(t *testing.T) { + app := testApp(t) + + a, err := app.createAttendee(Attendee{Name: "Alice", Email: "alice@test.com", TicketType: "GA"}) + if err != nil { + t.Fatal(err) + } + if a.ID == 0 || a.Name != "Alice" { + t.Errorf("create: got %+v", a) + } + + got, err := app.getAttendee(a.ID) + if err != nil || got == nil { + t.Fatal("get: not found") + } + if got.Email != "alice@test.com" { + t.Errorf("get: email = %q", got.Email) + } + + got.Name = "Alice Smith" + if err := app.updateAttendee(*got); err != nil { + t.Fatal(err) + } + got2, _ := app.getAttendee(a.ID) + if got2.Name != "Alice Smith" { + t.Errorf("update: name = %q", got2.Name) + } + + if err := app.deleteAttendee(a.ID); err != nil { + t.Fatal(err) + } + // getAttendee returns soft-deleted records; listAttendees filters them + attendees, _ := app.listAttendees("", "", "") + for _, at := range attendees { + if at.ID == a.ID { + t.Error("delete: still visible in list") + } + } +} + +func TestIncrementPartySize(t *testing.T) { + app := testApp(t) + + app.createAttendee(Attendee{Name: "Bob", TicketID: "ORD-100"}) + + merged, err := app.incrementPartySize("Bob", "ORD-100") + if err != nil || !merged { + t.Fatalf("increment: merged=%v, err=%v", merged, err) + } + + a, _ := app.getAttendee(1) + if a.PartySize != 2 { + t.Errorf("party_size = %d, want 2", a.PartySize) + } + + // Different ticket_id should not merge + merged2, _ := app.incrementPartySize("Bob", "ORD-200") + if merged2 { + t.Error("should not merge different ticket_id") + } +} + +func TestCheckInAttendee(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + + app.createAttendee(Attendee{Name: "Charlie"}) + // Set party_size directly since createAttendee defaults to 1 + app.db.Exec(`UPDATE attendees SET party_size = 3 WHERE id = 1`) + + // Check in 1 + a, err := app.checkInAttendee(1, admin.ID, 1) + if err != nil { + t.Fatal(err) + } + if a.CheckedInCount != 1 || !a.CheckedIn { + t.Errorf("after 1: count=%d, checked_in=%v", a.CheckedInCount, a.CheckedIn) + } + + // Check in 2 more (should cap at party_size=3) + a, _ = app.checkInAttendee(1, admin.ID, 5) + if a.CheckedInCount != 3 { + t.Errorf("after cap: count=%d, want 3", a.CheckedInCount) + } + + // Check in again — already full, should stay at 3 + a, _ = app.checkInAttendee(1, admin.ID, 1) + if a.CheckedInCount != 3 { + t.Errorf("after full: count=%d, want 3", a.CheckedInCount) + } +} + +func TestGenerateToken(t *testing.T) { + token, err := generateToken() + if err != nil { + t.Fatal(err) + } + if len(token) != 8 { + t.Errorf("token length = %d, want 8", len(token)) + } + for _, c := range token { + if !isValidTokenChar(c) { + t.Errorf("invalid char %c in token %s", c, token) + } + } +} + +func isValidTokenChar(c rune) bool { + for _, tc := range tokenChars { + if c == tc { + return true + } + } + return false +} + +func TestGenerateUniqueToken(t *testing.T) { + app := testApp(t) + token, err := app.generateUniqueToken() + if err != nil || len(token) != 8 { + t.Fatalf("token=%q, err=%v", token, err) + } +} + +func TestDepartmentsCRUD(t *testing.T) { + app := testApp(t) + + d, err := app.createDepartment(Department{Name: "Gate"}) + if err != nil { + t.Fatal(err) + } + if d.Name != "Gate" { + t.Errorf("name = %q", d.Name) + } + + depts, _ := app.listDepartments("") + if len(depts) != 1 { + t.Errorf("list: got %d", len(depts)) + } + + if err := app.deleteDepartment(d.ID); err != nil { + t.Fatal(err) + } +} + +func TestShiftsCRUD(t *testing.T) { + app := testApp(t) + + dept, _ := app.createDepartment(Department{Name: "Gate"}) + s, err := app.createShift(Shift{ + DepartmentID: dept.ID, + Name: "Morning", + Day: "2026-03-15", + StartTime: "08:00", + EndTime: "12:00", + Capacity: 5, + }) + if err != nil { + t.Fatal(err) + } + if s.Name != "Morning" || s.Capacity != 5 { + t.Errorf("create: %+v", s) + } + + got, _ := app.getShift(s.ID) + if got == nil || got.Day != "2026-03-15" { + t.Error("get: not found or wrong day") + } + + if err := app.deleteShift(s.ID); err != nil { + t.Fatal(err) + } +} + +func TestAssignAndUnassignShift(t *testing.T) { + app := testApp(t) + + dept, _ := app.createDepartment(Department{Name: "Gate"}) + deptID := dept.ID + s, _ := app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00", Capacity: 2}) + v, _ := app.createVolunteer(Volunteer{Name: "Dana", DepartmentID: &deptID}) + + if err := app.assignShift(v.ID, s.ID); err != nil { + t.Fatal(err) + } + count, _ := app.shiftAssignedCount(s.ID) + if count != 1 { + t.Errorf("assigned count = %d, want 1", count) + } + + if err := app.unassignShift(v.ID, s.ID); err != nil { + t.Fatal(err) + } + count, _ = app.shiftAssignedCount(s.ID) + if count != 0 { + t.Errorf("after unassign: count = %d, want 0", count) + } +} + +func TestCheckShiftConflict(t *testing.T) { + app := testApp(t) + + dept, _ := app.createDepartment(Department{Name: "Gate"}) + deptID := dept.ID + v, _ := app.createVolunteer(Volunteer{Name: "Eve", DepartmentID: &deptID}) + + s1, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Morning", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) + s2, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Overlap", Day: "2026-03-15", StartTime: "10:00", EndTime: "14:00"}) + s3, _ := app.createShift(Shift{DepartmentID: deptID, Name: "NoOverlap", Day: "2026-03-15", StartTime: "14:00", EndTime: "18:00"}) + + app.assignShift(v.ID, s1.ID) + + // s2 overlaps s1 (10:00-14:00 vs 08:00-12:00) + conflicts, err := app.checkShiftConflict(v.ID, s2.ID) + if err != nil { + t.Fatal(err) + } + if len(conflicts) != 1 { + t.Errorf("overlap: got %d conflicts, want 1", len(conflicts)) + } + + // s3 does not overlap s1 (14:00-18:00 vs 08:00-12:00) + conflicts, _ = app.checkShiftConflict(v.ID, s3.ID) + if len(conflicts) != 0 { + t.Errorf("no overlap: got %d conflicts, want 0", len(conflicts)) + } +} + +func TestCheckShiftConflictMidnight(t *testing.T) { + app := testApp(t) + + dept, _ := app.createDepartment(Department{Name: "Sound"}) + deptID := dept.ID + v, _ := app.createVolunteer(Volunteer{Name: "Frank", DepartmentID: &deptID}) + + // Night shift: 22:00-02:00 (spans midnight) + night, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Night", Day: "2026-03-15", StartTime: "22:00", EndTime: "02:00"}) + // Late shift: 23:00-03:00 (overlaps with night) + late, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Late", Day: "2026-03-15", StartTime: "23:00", EndTime: "03:00"}) + // Morning shift: 08:00-12:00 (no overlap with night) + morning, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Morning", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) + + app.assignShift(v.ID, night.ID) + + // Late should conflict with night + conflicts, _ := app.checkShiftConflict(v.ID, late.ID) + if len(conflicts) != 1 { + t.Errorf("midnight overlap: got %d conflicts, want 1", len(conflicts)) + } + + // Morning should not conflict with night + conflicts, _ = app.checkShiftConflict(v.ID, morning.ID) + if len(conflicts) != 0 { + t.Errorf("no midnight overlap: got %d conflicts, want 0", len(conflicts)) + } +} diff --git a/frontend/README.md b/frontend/README.md index 54a2631..36b1fa2 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,43 +1,34 @@ -# Svelte + Vite +# Turnpike Frontend -This template should help get you started developing with Svelte in Vite. +Svelte 5 + Vite PWA. Offline-first with Dexie (IndexedDB) and background sync. -## Recommended IDE Setup +## Development -[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode). +From the repo root with `direnv allow` (or Node.js 18+ installed): -## Need an official Svelte framework? - -Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more. - -## Technical considerations - -**Why use this over SvelteKit?** - -- It brings its own routing solution which might not be preferable for some users. -- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app. - -This template contains as little as possible to get started with Vite + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project. - -Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate. - -**Why include `.vscode/extensions.json`?** - -Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project. - -**Why enable `checkJs` in the JS template?** - -It is likely that most cases of changing variable types in runtime are likely to be accidental, rather than deliberate. This provides advanced typechecking out of the box. Should you like to take advantage of the dynamically-typed nature of JavaScript, it is trivial to change the configuration. - -**Why is HMR not preserving my local component state?** - -HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/sveltejs/svelte-hmr/tree/master/packages/svelte-hmr#preservation-of-local-state). - -If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR. - -```js -// store.js -// An extremely simple external store -import { writable } from 'svelte/store' -export default writable(0) +```sh +cd frontend +npm install +npm run dev ``` + +Runs on `:5173`, proxies `/api` to the Go backend on `:8180`. + +## Build + +```sh +npm run build +``` + +Output goes to `dist/`, which the Go binary embeds at compile time. + +## Architecture + +- `src/db.js` — Dexie schema, session management +- `src/api.js` — all API calls, injects `Authorization: Bearer` header +- `src/sync.js` — sync pull, SSE stream, outbox flush +- `src/pages/` — page components (one per route) +- `src/components/` — shared UI components +- `src/app.css` — global CSS custom properties (colors, spacing, type scale) + +All UI reads come from Dexie via `liveQuery()`, not direct API calls. Styles are scoped per component; no hardcoded color values. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d613636..461f480 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,8 +12,201 @@ }, "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" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.29", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.29.tgz", + "integrity": "sha512-jx9GjkkP5YHuTmko2eWAvpPnb0mB4mGRr2U7XwVNwevm8nlpobZEVk+GNmiYMk2VuA75v+plfXWyroWKmICZXg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -458,6 +651,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz", + "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -858,6 +1069,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", @@ -907,6 +1125,24 @@ "vite": "^6.3.0 || ^7.0.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -921,6 +1157,117 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -934,6 +1281,16 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/aria-query": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", @@ -944,6 +1301,16 @@ "node": ">= 0.4" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -954,6 +1321,26 @@ "node": ">= 0.4" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -964,6 +1351,75 @@ "node": ">=6" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -987,6 +1443,26 @@ "integrity": "sha512-5EeoQpJvMKHe6zWt/FSIIuRa3CWlZeIl6zKXt+Lz7BU6RoRRLgX9dZEynRfXrkLcldKYCBiz7xekTEylnie1Ug==", "license": "Apache-2.0" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -1046,6 +1522,36 @@ "@jridgewell/sourcemap-codec": "^1.4.15" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1079,6 +1585,54 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -1089,6 +1643,47 @@ "@types/estree": "^1.0.6" } }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/locate-character": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", @@ -1096,6 +1691,16 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1106,6 +1711,20 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1136,6 +1755,26 @@ ], "license": "MIT" }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1185,6 +1824,26 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -1230,6 +1889,26 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1240,6 +1919,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/svelte": { "version": "5.53.6", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.6.tgz", @@ -1268,6 +1961,30 @@ "node": ">=18" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1285,6 +2002,72 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.24.tgz", + "integrity": "sha512-1r6vQTTt1rUiJkI5vX7KG8PR342Ru/5Oh13kEQP2SMbRSZpOey9SrBe27IDxkoWulx8ShWu4K6C0BkctP8Z1bQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.24" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.24.tgz", + "integrity": "sha512-pj7yygNMoMRqG7ML2SDQ0xNIOfN3IBDUcPVM2Sg6hP96oFNN2nqnzHreT3z9xLq85IWJyNTvD38O002DdOrPMw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", @@ -1380,6 +2163,166 @@ } } }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9ca9526..68ad1f3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" diff --git a/frontend/src/api.test.js b/frontend/src/api.test.js new file mode 100644 index 0000000..fed07ad --- /dev/null +++ b/frontend/src/api.test.js @@ -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') + }) +}) diff --git a/frontend/src/db.test.js b/frontend/src/db.test.js new file mode 100644 index 0000000..36d3c5e --- /dev/null +++ b/frontend/src/db.test.js @@ -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') + }) +}) diff --git a/frontend/src/sync.js b/frontend/src/sync.js index f30c80a..05b16c0 100644 --- a/frontend/src/sync.js +++ b/frontend/src/sync.js @@ -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) } }) } diff --git a/frontend/src/sync.test.js b/frontend/src/sync.test.js new file mode 100644 index 0000000..4f69343 --- /dev/null +++ b/frontend/src/sync.test.js @@ -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') + }) +}) diff --git a/frontend/src/test-setup.js b/frontend/src/test-setup.js new file mode 100644 index 0000000..d6e9324 --- /dev/null +++ b/frontend/src/test-setup.js @@ -0,0 +1 @@ +import 'fake-indexeddb/auto' diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 1f9d6aa..0f71087 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -12,4 +12,8 @@ export default defineConfig({ outDir: 'dist', emptyOutDir: true, }, + test: { + environment: 'jsdom', + setupFiles: ['./src/test-setup.js'], + }, }) diff --git a/handle_attendees_test.go b/handle_attendees_test.go new file mode 100644 index 0000000..3d68911 --- /dev/null +++ b/handle_attendees_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestAttendeesListCreateDelete(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + // Create + req := testAuthRequest("POST", "/api/attendees", map[string]string{"name": "Alice"}, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("create: status = %d\nbody: %s", w.Code, w.Body.String()) + } + created := parseJSON(t, w) + id := created["id"].(float64) + + // List + req = testAuthRequest("GET", "/api/attendees", nil, token) + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("list: status = %d", w.Code) + } + list := parseJSON(t, w) + attendees := list["attendees"].([]any) + if len(attendees) != 1 { + t.Errorf("list: got %d, want 1", len(attendees)) + } + + // Delete + req = testAuthRequest("DELETE", "/api/attendees/"+itoa(int(id)), nil, token) + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusNoContent { + t.Errorf("delete: status = %d", w.Code) + } + + // List again — should be empty + req = testAuthRequest("GET", "/api/attendees", nil, token) + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + list = parseJSON(t, w) + if a2, ok := list["attendees"].([]any); ok && len(a2) != 0 { + t.Errorf("after delete: got %d, want 0", len(a2)) + } +} + +func TestCheckInAttendeeHandler(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + app.createAttendee(Attendee{Name: "Bob"}) + app.db.Exec(`UPDATE attendees SET party_size = 3 WHERE id = 1`) + + // Check in 1 + req := testAuthRequest("POST", "/api/attendees/1/checkin", map[string]int{"count": 1}, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("checkin: status = %d\nbody: %s", w.Code, w.Body.String()) + } + result := parseJSON(t, w) + attendee := result["attendee"].(map[string]any) + if attendee["checked_in_count"] != float64(1) { + t.Errorf("checked_in_count = %v, want 1", attendee["checked_in_count"]) + } +} + +func TestGateRoleCanCheckIn(t *testing.T) { + app := testApp(t) + gate := testUserWithRole(t, app, "gateuser", "gate", []int{}) + token := testToken(t, app, gate) + mux := testMux(app) + + app.createAttendee(Attendee{Name: "Charlie"}) + + req := testAuthRequest("POST", "/api/attendees/1/checkin", nil, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("gate checkin: status = %d", w.Code) + } +} + +func TestGateRoleCannotDelete(t *testing.T) { + app := testApp(t) + gate := testUserWithRole(t, app, "gateuser", "gate", []int{}) + token := testToken(t, app, gate) + mux := testMux(app) + + app.createAttendee(Attendee{Name: "Charlie"}) + + req := testAuthRequest("DELETE", "/api/attendees/1", nil, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Errorf("gate delete: status = %d, want 403", w.Code) + } +} diff --git a/handle_import_test.go b/handle_import_test.go new file mode 100644 index 0000000..32c443d --- /dev/null +++ b/handle_import_test.go @@ -0,0 +1,142 @@ +package main + +import ( + "bytes" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" +) + +func postCSV(t *testing.T, mux *http.ServeMux, token, csv string) *httptest.ResponseRecorder { + t.Helper() + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + part, _ := writer.CreateFormFile("csv", "attendees.csv") + io.WriteString(part, csv) + writer.Close() + + req := httptest.NewRequest("POST", "/api/import", &buf) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + return w +} + +func TestImportCrowdWorkFormat(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + csv := "Patron Name,Patron Email,Order Number,Tier Name\nAlice,alice@test.com,ORD-1,GA\nBob,bob@test.com,ORD-2,VIP\n" + w := postCSV(t, mux, token, csv) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d\nbody: %s", w.Code, w.Body.String()) + } + result := parseJSON(t, w) + if result["inserted"] != float64(2) { + t.Errorf("inserted = %v, want 2", result["inserted"]) + } +} + +func TestImportGenericFormat(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + csv := "name,email,ticket_id,ticket_type,note\nAlice,alice@test.com,T1,GA,VIP guest\n" + w := postCSV(t, mux, token, csv) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + result := parseJSON(t, w) + if result["inserted"] != float64(1) { + t.Errorf("inserted = %v", result["inserted"]) + } +} + +func TestImportPartySizeDedup(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + // 3 rows same name+order = 1 record, party_size=3 + csv := "Patron Name,Patron Email,Order Number,Tier Name\nAlice,alice@test.com,ORD-1,GA\nAlice,alice@test.com,ORD-1,GA\nAlice,alice@test.com,ORD-1,GA\n" + w := postCSV(t, mux, token, csv) + + result := parseJSON(t, w) + if result["inserted"] != float64(1) { + t.Errorf("inserted = %v, want 1", result["inserted"]) + } + if result["grouped"] != float64(2) { + t.Errorf("grouped = %v, want 2", result["grouped"]) + } + + attendees, _ := app.listAttendees("", "", "") + if len(attendees) != 1 { + t.Fatalf("attendee count = %d, want 1", len(attendees)) + } + if attendees[0].PartySize != 3 { + t.Errorf("party_size = %d, want 3", attendees[0].PartySize) + } +} + +func TestImportReimportSkips(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + csv := "name\nAlice\nBob\n" + postCSV(t, mux, token, csv) + + // Re-import same data + w := postCSV(t, mux, token, csv) + result := parseJSON(t, w) + if result["inserted"] != float64(0) { + t.Errorf("re-import inserted = %v, want 0", result["inserted"]) + } + if result["skipped"] != float64(2) { + t.Errorf("skipped = %v, want 2", result["skipped"]) + } +} + +func TestImportMissingNameColumn(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + csv := "email,phone\nalice@test.com,555-1234\n" + w := postCSV(t, mux, token, csv) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +func TestImportBOM(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + // BOM-encoded CSV + csv := "\xef\xbb\xbfname,email\nAlice,alice@test.com\n" + w := postCSV(t, mux, token, csv) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + result := parseJSON(t, w) + if result["inserted"] != float64(1) { + t.Errorf("inserted = %v, want 1", result["inserted"]) + } +} diff --git a/handle_kiosk.go b/handle_kiosk.go index c23b981..c782afb 100644 --- a/handle_kiosk.go +++ b/handle_kiosk.go @@ -1,6 +1,7 @@ package main import ( + "errors" "net/http" "strconv" ) @@ -87,15 +88,12 @@ func (app *App) handleKioskClaim(w http.ResponseWriter, r *http.Request) { writeError(w, "shift not found", http.StatusNotFound) return } - if shift.Capacity > 0 { - count, _ := app.shiftAssignedCount(shiftID) - if count >= shift.Capacity { + + if err := app.assignShiftWithCapacity(v.ID, shiftID, shift.Capacity); err != nil { + if errors.Is(err, errShiftFull) { writeError(w, "shift is full", http.StatusConflict) return } - } - - if err := app.assignShift(v.ID, shiftID); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } diff --git a/handle_kiosk_test.go b/handle_kiosk_test.go new file mode 100644 index 0000000..f5c5057 --- /dev/null +++ b/handle_kiosk_test.go @@ -0,0 +1,167 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func setupKiosk(t *testing.T) (*App, *http.ServeMux, string) { + t.Helper() + app := testApp(t) + mux := testMux(app) + + dept, _ := app.createDepartment(Department{Name: "Gate"}) + deptID := dept.ID + + // Create attendee with token + a, _ := app.createAttendee(Attendee{Name: "Alice", Email: "alice@test.com"}) + token, _ := app.generateUniqueToken() + app.db.Exec(`UPDATE attendees SET volunteer_token = ? WHERE id = ?`, token, a.ID) + + // Create linked volunteer + app.createVolunteer(Volunteer{Name: "Alice", AttendeeID: &a.ID, DepartmentID: &deptID}) + + // Create shifts + app.createShift(Shift{DepartmentID: deptID, Name: "Morning", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00", Capacity: 2}) + app.createShift(Shift{DepartmentID: deptID, Name: "Afternoon", Day: "2026-03-15", StartTime: "14:00", EndTime: "18:00", Capacity: 1}) + + return app, mux, token +} + +func TestKioskGetValid(t *testing.T) { + _, mux, token := setupKiosk(t) + + req := httptest.NewRequest("GET", "/api/v/"+token, nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d\nbody: %s", w.Code, w.Body.String()) + } + result := parseJSON(t, w) + if result["volunteer"] == nil { + t.Error("missing volunteer") + } + available := result["available"].([]any) + if len(available) != 2 { + t.Errorf("available = %d, want 2", len(available)) + } +} + +func TestKioskGetInvalidToken(t *testing.T) { + _, mux, _ := setupKiosk(t) + + req := httptest.NewRequest("GET", "/api/v/BADTOKEN", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } +} + +func TestKioskClaimShift(t *testing.T) { + _, mux, token := setupKiosk(t) + + req := httptest.NewRequest("POST", "/api/v/"+token+"/shifts/1", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("claim: status = %d\nbody: %s", w.Code, w.Body.String()) + } + result := parseJSON(t, w) + shifts := result["shifts"].([]any) + if len(shifts) != 1 { + t.Errorf("assigned shifts = %d, want 1", len(shifts)) + } +} + +func TestKioskClaimConflict(t *testing.T) { + app, mux, token := setupKiosk(t) + + dept, _ := app.createDepartment(Department{Name: "Build"}) + deptID := dept.ID + // Create overlapping shift + app.createShift(Shift{DepartmentID: deptID, Name: "Overlap", Day: "2026-03-15", StartTime: "10:00", EndTime: "14:00"}) + + // Claim morning (08:00-12:00) + req := httptest.NewRequest("POST", "/api/v/"+token+"/shifts/1", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("first claim: status = %d", w.Code) + } + + // Claim overlapping shift (10:00-14:00) — should get 409 + req = httptest.NewRequest("POST", "/api/v/"+token+"/shifts/3", nil) + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusConflict { + t.Fatalf("conflict: status = %d, want 409\nbody: %s", w.Code, w.Body.String()) + } + result := parseJSON(t, w) + if result["conflict"] != true { + t.Error("missing conflict flag") + } +} + +func TestKioskClaimForce(t *testing.T) { + app, mux, token := setupKiosk(t) + + dept, _ := app.createDepartment(Department{Name: "Build"}) + deptID := dept.ID + app.createShift(Shift{DepartmentID: deptID, Name: "Overlap", Day: "2026-03-15", StartTime: "10:00", EndTime: "14:00"}) + + // Claim morning + req := httptest.NewRequest("POST", "/api/v/"+token+"/shifts/1", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + // Force-claim overlapping shift + req = httptest.NewRequest("POST", "/api/v/"+token+"/shifts/3?force=true", nil) + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("force: status = %d, want 200", w.Code) + } +} + +func TestKioskClaimFull(t *testing.T) { + app, mux, token := setupKiosk(t) + + // Shift 2 has capacity 1. Fill it with another volunteer. + dept, _ := app.createDepartment(Department{Name: "Build"}) + deptID := dept.ID + other, _ := app.createVolunteer(Volunteer{Name: "Other", DepartmentID: &deptID}) + app.assignShift(other.ID, 2) // fills the capacity-1 shift + + req := httptest.NewRequest("POST", "/api/v/"+token+"/shifts/2", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusConflict { + t.Errorf("full: status = %d, want 409\nbody: %s", w.Code, w.Body.String()) + } +} + +func TestKioskUnclaim(t *testing.T) { + _, mux, token := setupKiosk(t) + + // Claim then unclaim + req := httptest.NewRequest("POST", "/api/v/"+token+"/shifts/1", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + req = httptest.NewRequest("DELETE", "/api/v/"+token+"/shifts/1", nil) + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("unclaim: status = %d", w.Code) + } + result := parseJSON(t, w) + shifts := result["shifts"].([]any) + if len(shifts) != 0 { + t.Errorf("after unclaim: shifts = %d, want 0", len(shifts)) + } +} diff --git a/handle_settings_test.go b/handle_settings_test.go new file mode 100644 index 0000000..28db093 --- /dev/null +++ b/handle_settings_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetSettings(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + req := testAuthRequest("GET", "/api/settings", nil, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + result := parseJSON(t, w) + if result["smtp_host"] == nil { + t.Error("missing smtp_host key") + } +} + +func TestUpdateSettings(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + req := testAuthRequest("PUT", "/api/settings", map[string]any{ + "smtp_host": "smtp.example.com", + "smtp_port": 587, + "smtp_password": "secret", + "base_url": "https://turnpike.example.com", + }, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d\nbody: %s", w.Code, w.Body.String()) + } + result := parseJSON(t, w) + if result["smtp_host"] != "smtp.example.com" { + t.Errorf("smtp_host = %v", result["smtp_host"]) + } + if result["smtp_password"] != "***" { + t.Errorf("smtp_password = %v, want '***'", result["smtp_password"]) + } + if result["base_url"] != "https://turnpike.example.com" { + t.Errorf("base_url = %v", result["base_url"]) + } +} + +func TestSettingsNonAdminRejected(t *testing.T) { + app := testApp(t) + gate := testUserWithRole(t, app, "gateuser", "gate", []int{}) + token := testToken(t, app, gate) + mux := testMux(app) + + req := testAuthRequest("GET", "/api/settings", nil, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403", w.Code) + } +} diff --git a/handle_shifts_test.go b/handle_shifts_test.go new file mode 100644 index 0000000..bc7d629 --- /dev/null +++ b/handle_shifts_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestShiftsCRUDHandler(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + dept, _ := app.createDepartment(Department{Name: "Gate"}) + + // Create + req := testAuthRequest("POST", "/api/shifts", map[string]any{ + "department_id": dept.ID, + "name": "Morning", + "day": "2026-03-15", + "start_time": "08:00", + "end_time": "12:00", + "capacity": 5, + }, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("create: status = %d\nbody: %s", w.Code, w.Body.String()) + } + + // List + req = testAuthRequest("GET", "/api/shifts", nil, token) + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("list: status = %d", w.Code) + } + + // Delete + req = testAuthRequest("DELETE", "/api/shifts/1", nil, token) + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusNoContent { + t.Errorf("delete: status = %d", w.Code) + } +} + +func TestShiftAssignVolunteer(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + dept, _ := app.createDepartment(Department{Name: "Gate"}) + deptID := dept.ID + app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) + app.createVolunteer(Volunteer{Name: "Alice", DepartmentID: &deptID}) + + // Assign + req := testAuthRequest("POST", "/api/shifts/1/volunteers", map[string]any{ + "volunteer_id": 1, + }, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusNoContent { + t.Fatalf("assign: status = %d\nbody: %s", w.Code, w.Body.String()) + } + + // Unassign + req = testAuthRequest("DELETE", "/api/shifts/1/volunteers/1", nil, token) + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusNoContent { + t.Errorf("unassign: status = %d", w.Code) + } +} + +func TestShiftAssignConflict(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + dept, _ := app.createDepartment(Department{Name: "Gate"}) + deptID := dept.ID + app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) + app.createShift(Shift{DepartmentID: deptID, Name: "Overlap", Day: "2026-03-15", StartTime: "10:00", EndTime: "14:00"}) + app.createVolunteer(Volunteer{Name: "Alice", DepartmentID: &deptID}) + + // Assign to first shift + app.assignShift(1, 1) + + // Try to assign to overlapping shift — should get 409 + req := testAuthRequest("POST", "/api/shifts/2/volunteers", map[string]any{ + "volunteer_id": 1, + }, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusConflict { + t.Fatalf("conflict: status = %d, want 409", w.Code) + } +} + +func TestShiftReorder(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + dept, _ := app.createDepartment(Department{Name: "Gate"}) + deptID := dept.ID + app.createShift(Shift{DepartmentID: deptID, Name: "A", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) + app.createShift(Shift{DepartmentID: deptID, Name: "B", Day: "2026-03-15", StartTime: "12:00", EndTime: "16:00"}) + + req := testAuthRequest("POST", "/api/shifts/reorder", []map[string]int{ + {"id": 1, "position": 2}, + {"id": 2, "position": 1}, + }, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusNoContent { + t.Errorf("reorder: status = %d\nbody: %s", w.Code, w.Body.String()) + } + + s1, _ := app.getShift(1) + s2, _ := app.getShift(2) + if s1.Position != 2 || s2.Position != 1 { + t.Errorf("positions: s1=%d, s2=%d, want 2,1", s1.Position, s2.Position) + } +} diff --git a/handle_sync_test.go b/handle_sync_test.go new file mode 100644 index 0000000..03c529e --- /dev/null +++ b/handle_sync_test.go @@ -0,0 +1,110 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestSyncPullFull(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + app.createAttendee(Attendee{Name: "Alice"}) + dept, _ := app.createDepartment(Department{Name: "Gate"}) + deptID := dept.ID + app.createVolunteer(Volunteer{Name: "Alice", DepartmentID: &deptID}) + app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) + + req := testAuthRequest("GET", "/api/sync/pull", nil, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + result := parseJSON(t, w) + + if result["server_time"] == nil { + t.Error("missing server_time") + } + attendees := result["attendees"].([]any) + if len(attendees) != 1 { + t.Errorf("attendees = %d, want 1", len(attendees)) + } + depts := result["departments"].([]any) + if len(depts) != 1 { + t.Errorf("departments = %d, want 1", len(depts)) + } +} + +func TestSyncPullIncremental(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + app.createAttendee(Attendee{Name: "Alice"}) + // Backdate Alice so she falls before the "since" cutoff + app.db.Exec(`UPDATE attendees SET updated_at = '2026-01-01T00:00:00Z' WHERE name = 'Alice'`) + + since := "2026-01-01T12:00:00Z" + + // Bob created with default updated_at (now), which is after our since + app.createAttendee(Attendee{Name: "Bob"}) + + req := testAuthRequest("GET", "/api/sync/pull?since="+since, nil, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + result := parseJSON(t, w) + attendees := result["attendees"].([]any) + // Should only include Bob (created after `since`) + if len(attendees) != 1 { + t.Errorf("incremental: got %d attendees, want 1", len(attendees)) + } + if len(attendees) == 1 { + a := attendees[0].(map[string]any) + if a["name"] != "Bob" { + t.Errorf("name = %v, want Bob", a["name"]) + } + } +} + +func TestSyncPullIncludesSoftDeleted(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + a, _ := app.createAttendee(Attendee{Name: "Alice"}) + // Backdate Alice's creation so the since cutoff is between creation and deletion + app.db.Exec(`UPDATE attendees SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, a.ID) + + since := "2026-01-01T12:00:00Z" + + // Delete updates updated_at to now(), which is after our since + app.deleteAttendee(a.ID) + + req := testAuthRequest("GET", "/api/sync/pull?since="+since, nil, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + var result struct { + Attendees []struct { + ID int `json:"id"` + DeletedAt *string `json:"deleted_at"` + } `json:"attendees"` + } + json.Unmarshal(w.Body.Bytes(), &result) + + if len(result.Attendees) != 1 { + t.Fatalf("got %d attendees, want 1", len(result.Attendees)) + } + if result.Attendees[0].DeletedAt == nil { + t.Error("deleted_at should be set for soft-deleted record") + } +} diff --git a/testutil_test.go b/testutil_test.go new file mode 100644 index 0000000..8f58833 --- /dev/null +++ b/testutil_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func testApp(t *testing.T) *App { + t.Helper() + db, err := initDB(":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + // Ensure config table exists (normally created by getOrCreateSecret) + db.Exec(`CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)`) + return &App{ + db: db, + secret: "test-secret", + tokenExpiry: 24, + broker: newBroker(), + } +} + +func testAdminUser(t *testing.T, app *App) *User { + t.Helper() + hash, _ := hashPassword("admin123") + u, err := app.createUser("admin", hash, "admin", []int{}) + if err != nil { + t.Fatal(err) + } + return u +} + +func testUserWithRole(t *testing.T, app *App, username, role string, deptIDs []int) *User { + t.Helper() + hash, _ := hashPassword(username + "123") + u, err := app.createUser(username, hash, role, deptIDs) + if err != nil { + t.Fatal(err) + } + return u +} + +func testToken(t *testing.T, app *App, user *User) string { + t.Helper() + token, err := app.signToken(user) + if err != nil { + t.Fatal(err) + } + return token +} + +func testMux(app *App) *http.ServeMux { + mux := http.NewServeMux() + app.registerRoutes(mux) + return mux +} + +func testRequest(method, path string, body any) *http.Request { + var buf bytes.Buffer + if body != nil { + json.NewEncoder(&buf).Encode(body) + } + req := httptest.NewRequest(method, path, &buf) + req.Header.Set("Content-Type", "application/json") + return req +} + +func testAuthRequest(method, path string, body any, token string) *http.Request { + req := testRequest(method, path, body) + req.Header.Set("Authorization", "Bearer "+token) + return req +} + +func itoa(i int) string { + return fmt.Sprintf("%d", i) +} + +func parseJSON(t *testing.T, w *httptest.ResponseRecorder) map[string]any { + t.Helper() + var result map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { + t.Fatalf("parse JSON: %v\nbody: %s", err, w.Body.String()) + } + return result +}