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..0d453bb --- /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: "Titania", Email: "titania@test.com", TicketType: "GA"}) + if err != nil { + t.Fatal(err) + } + if a.ID == 0 || a.Name != "Titania" { + 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 != "titania@test.com" { + t.Errorf("get: email = %q", got.Email) + } + + got.Name = "Titania Fairweather" + if err := app.updateAttendee(*got); err != nil { + t.Fatal(err) + } + got2, _ := app.getAttendee(a.ID) + if got2.Name != "Titania Fairweather" { + 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: "Oberon", TicketID: "ORD-100"}) + + merged, err := app.incrementPartySize("Oberon", "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("Oberon", "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: "Puck"}) + // 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: "Helena", 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: "Hermia", 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: "Lysander", 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/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..25e0dd9 --- /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: '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') + }) +}) 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..2c8915e --- /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: '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') + }) +}) 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/go.mod b/go.mod index fef0a1d..a1c34fd 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module turnpike -go 1.24 +go 1.24.0 require ( github.com/golang-jwt/jwt/v5 v5.3.1 diff --git a/handle_attendees_test.go b/handle_attendees_test.go new file mode 100644 index 0000000..827a4b2 --- /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": "Titania"}, 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: "Oberon"}) + 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: "Puck"}) + + 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: "Puck"}) + + 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..a062c53 --- /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\nTitania,titania@test.com,ORD-1,GA\nOberon,oberon@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\nTitania,titania@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\nTitania,titania@test.com,ORD-1,GA\nTitania,titania@test.com,ORD-1,GA\nTitania,titania@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\nTitania\nOberon\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\ntitania@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\nTitania,titania@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..0eb252b --- /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: "Titania", Email: "titania@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: "Titania", 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..8c629b1 --- /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: "Titania", 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: "Titania", 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..c5d759a --- /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: "Titania"}) + dept, _ := app.createDepartment(Department{Name: "Gate"}) + deptID := dept.ID + app.createVolunteer(Volunteer{Name: "Titania", 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: "Titania"}) + // Backdate Titania so she falls before the "since" cutoff + app.db.Exec(`UPDATE attendees SET updated_at = '2026-01-01T00:00:00Z' WHERE name = 'Titania'`) + + since := "2026-01-01T12:00:00Z" + + // Oberon created with default updated_at (now), which is after our since + app.createAttendee(Attendee{Name: "Oberon"}) + + 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 Oberon (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"] != "Oberon" { + t.Errorf("name = %v, want Oberon", 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: "Titania"}) + // Backdate Titania'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 +}