Compare commits

...

2 commits

21 changed files with 2550 additions and 77 deletions

View file

@ -1,4 +1,4 @@
.PHONY: build frontend-build dev clean .PHONY: build frontend-build dev clean test
build: frontend-build build: frontend-build
CGO_ENABLED=0 go build -o turnpike . CGO_ENABLED=0 go build -o turnpike .
@ -11,6 +11,10 @@ dev:
@echo " Terminal 1: go run . --db dev.db" @echo " Terminal 1: go run . --db dev.db"
@echo " Terminal 2: cd frontend && npm run dev" @echo " Terminal 2: cd frontend && npm run dev"
test:
go test ./...
cd frontend && npx vitest run
clean: clean:
rm -f turnpike dev.db rm -f turnpike dev.db
rm -rf frontend/dist rm -rf frontend/dist

127
auth_test.go Normal file
View file

@ -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"])
}
}

107
db.go
View file

@ -134,6 +134,7 @@ func migrateV2(db *sql.DB) error {
addColumnIfMissing(db, "attendees", "party_size INTEGER NOT NULL DEFAULT 1") addColumnIfMissing(db, "attendees", "party_size INTEGER NOT NULL DEFAULT 1")
addColumnIfMissing(db, "attendees", "checked_in_count INTEGER NOT NULL DEFAULT 0") addColumnIfMissing(db, "attendees", "checked_in_count INTEGER NOT NULL DEFAULT 0")
addColumnIfMissing(db, "shifts", "position 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). // Widen the uniqueness constraint from name-only to (name, ticket_id).
db.Exec(`DROP INDEX IF EXISTS idx_attendees_name`) 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`) 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 { type VolunteerShift struct {
VolunteerID int `json:"volunteer_id"` VolunteerID int `json:"volunteer_id"`
ShiftID int `json:"shift_id"` ShiftID int `json:"shift_id"`
Confirmed bool `json:"confirmed"` Confirmed bool `json:"confirmed"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
DeletedAt *string `json:"deleted_at"`
} }
// --- Event --- // --- Event ---
@ -413,21 +415,28 @@ func (app *App) countUsers() (int, error) {
const tokenChars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" const tokenChars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
func generateToken() string { func generateToken() (string, error) {
b := make([]byte, 8) 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) result := make([]byte, 8)
for i, v := range b { for i, v := range b {
result[i] = tokenChars[int(v)%len(tokenChars)] result[i] = tokenChars[int(v)%len(tokenChars)]
} }
return string(result) return string(result), nil
} }
func (app *App) generateUniqueToken() (string, error) { func (app *App) generateUniqueToken() (string, error) {
for range 10 { for range 10 {
t := generateToken() t, err := generateToken()
if err != nil {
return "", err
}
var count int 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 { if count == 0 {
return t, nil return t, nil
} }
@ -452,13 +461,15 @@ func (app *App) generateTokensForAll() (int, error) {
if err != nil { if err != nil {
return 0, err return 0, err
} }
defer rows.Close()
var ids []int var ids []int
for rows.Next() { for rows.Next() {
var id int 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) ids = append(ids, id)
} }
rows.Close()
count := 0 count := 0
for _, id := range ids { 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. // shiftAssignedCount returns the number of volunteers currently assigned to a shift.
func (app *App) shiftAssignedCount(shiftID int) (int, error) { func (app *App) shiftAssignedCount(shiftID int) (int, error) {
var count int 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 return count, err
} }
@ -927,21 +938,40 @@ func (app *App) checkShiftConflict(volunteerID, shiftID int) ([]Shift, error) {
SELECT `+shiftColsS+` SELECT `+shiftColsS+`
FROM shifts s FROM shifts s
JOIN volunteer_shifts vs ON vs.shift_id = s.id 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) volunteerID, target.Day, shiftID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var conflicts []Shift var conflicts []Shift
for _, s := range existing { for _, s := range existing {
// Overlap: one starts before the other ends (HH:MM string comparison works for same-day) if timesOverlap(s.StartTime, s.EndTime, target.StartTime, target.EndTime) {
if s.StartTime < target.EndTime && target.StartTime < s.EndTime {
conflicts = append(conflicts, s) conflicts = append(conflicts, s)
} }
} }
return conflicts, nil 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. // reorderShifts updates the position field for each given shift.
func (app *App) reorderShifts(positions []struct{ ID, Position int }) error { func (app *App) reorderShifts(positions []struct{ ID, Position int }) error {
for _, p := range positions { 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 { func (app *App) assignShift(volunteerID, shiftID int) error {
_, err := app.db.Exec( _, err := app.db.Exec(
`INSERT INTO volunteer_shifts (volunteer_id, shift_id, updated_at) VALUES (?, ?, ?) `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(), volunteerID, shiftID, now(),
) )
return err 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 { func (app *App) unassignShift(volunteerID, shiftID int) error {
_, err := app.db.Exec( _, 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 return err
} }
@ -976,10 +1039,10 @@ func (app *App) listVolunteerShifts(since string) ([]VolunteerShift, error) {
var q string var q string
var args []any var args []any
if since != "" { 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) args = append(args, since)
} else { } 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...) rows, err := app.db.Query(q, args...)
if err != nil { if err != nil {
@ -990,7 +1053,7 @@ func (app *App) listVolunteerShifts(since string) ([]VolunteerShift, error) {
for rows.Next() { for rows.Next() {
var vs VolunteerShift var vs VolunteerShift
var confirmed int 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 vs.Confirmed = confirmed == 1
result = append(result, vs) result = append(result, vs)
} }
@ -1003,7 +1066,7 @@ func (app *App) listShiftsForVolunteer(volunteerID int) ([]Shift, error) {
SELECT `+shiftColsS+` SELECT `+shiftColsS+`
FROM shifts s FROM shifts s
JOIN volunteer_shifts vs ON vs.shift_id = s.id 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) 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 FROM shifts s
WHERE s.department_id = ? AND s.deleted_at IS NULL WHERE s.department_id = ? AND s.deleted_at IS NULL
AND (s.capacity = 0 OR ( 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) ) < s.capacity)
ORDER BY s.day, s.position, s.start_time`, deptID) ORDER BY s.day, s.position, s.start_time`, deptID)
} }

275
db_test.go Normal file
View file

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

View file

@ -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? ```sh
cd frontend
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. npm install
npm run dev
## 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)
``` ```
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.

File diff suppressed because it is too large Load diff

View file

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

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

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

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

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

View file

@ -40,6 +40,9 @@ export async function syncPull() {
} }
if (data.volunteer_shifts?.length) { if (data.volunteer_shifts?.length) {
await db.volunteer_shifts.bulkPut(data.volunteer_shifts) 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 = new EventSource(`/api/sync/stream?token=${encodeURIComponent(session.token)}`)
sseSource.onmessage = (e) => { sseSource.onmessage = async (e) => {
try { try {
const payload = JSON.parse(e.data) const payload = JSON.parse(e.data)
if (payload.event === 'checkin') { if (payload.event === 'checkin') {
// Apply check-in to local Dexie immediately
if (payload.data?.type === 'attendee' && payload.data?.attendee) { 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) { if (payload.data?.type === 'volunteer' && payload.data?.volunteer) {
db.volunteers.put(payload.data.volunteer) await db.volunteers.put(payload.data.volunteer)
} }
onEvent?.(payload) onEvent?.(payload)
} }
} catch {} } catch (err) {
console.warn('SSE message error:', err.message)
}
} }
sseSource.onerror = () => { sseSource.onerror = () => {
sseSource?.close() sseSource?.close()
sseSource = null sseSource = null
// Reconnect after 5s setTimeout(() => {
setTimeout(connect, 5000) connect()
syncPull()
}, 5000)
} }
}) })
} }

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

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

View file

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

View file

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

109
handle_attendees_test.go Normal file
View file

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

142
handle_import_test.go Normal file
View file

@ -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"])
}
}

View file

@ -1,6 +1,7 @@
package main package main
import ( import (
"errors"
"net/http" "net/http"
"strconv" "strconv"
) )
@ -87,15 +88,12 @@ func (app *App) handleKioskClaim(w http.ResponseWriter, r *http.Request) {
writeError(w, "shift not found", http.StatusNotFound) writeError(w, "shift not found", http.StatusNotFound)
return return
} }
if shift.Capacity > 0 {
count, _ := app.shiftAssignedCount(shiftID) if err := app.assignShiftWithCapacity(v.ID, shiftID, shift.Capacity); err != nil {
if count >= shift.Capacity { if errors.Is(err, errShiftFull) {
writeError(w, "shift is full", http.StatusConflict) writeError(w, "shift is full", http.StatusConflict)
return return
} }
}
if err := app.assignShift(v.ID, shiftID); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
return return
} }

167
handle_kiosk_test.go Normal file
View file

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

71
handle_settings_test.go Normal file
View file

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

131
handle_shifts_test.go Normal file
View file

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

110
handle_sync_test.go Normal file
View file

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

91
testutil_test.go Normal file
View file

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