Added tests, shift 'delete'. Fixed overnight shifts, sync, error handling.
This commit is contained in:
parent
c9180490a4
commit
f2aa04db15
20 changed files with 2521 additions and 39 deletions
6
Makefile
6
Makefile
|
|
@ -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
127
auth_test.go
Normal 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
107
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", "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
275
db_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
945
frontend/package-lock.json
generated
945
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,12 +6,17 @@
|
||||||
"scripts": {
|
"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
98
frontend/src/api.test.js
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { db, saveSession, clearSession } from './db.js'
|
||||||
|
|
||||||
|
// Must import api after fake-indexeddb is initialized (via test-setup.js)
|
||||||
|
const { apiFetch, apiJSON, api } = await import('./api.js')
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await Promise.all(db.tables.map(t => t.clear()))
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
function mockFetch(body = {}, status = 200) {
|
||||||
|
const fn = vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
statusText: 'OK',
|
||||||
|
json: () => Promise.resolve(body),
|
||||||
|
text: () => Promise.resolve(JSON.stringify(body)),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
globalThis.fetch = fn
|
||||||
|
return fn
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('apiFetch', () => {
|
||||||
|
it('adds Authorization header when session exists', async () => {
|
||||||
|
await saveSession('mytoken', { id: 1 })
|
||||||
|
const f = mockFetch()
|
||||||
|
await apiFetch('/api/test')
|
||||||
|
expect(f).toHaveBeenCalledTimes(1)
|
||||||
|
const [, opts] = f.mock.calls[0]
|
||||||
|
expect(opts.headers['Authorization']).toBe('Bearer mytoken')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits Authorization when no session', async () => {
|
||||||
|
const f = mockFetch()
|
||||||
|
await apiFetch('/api/test')
|
||||||
|
const [, opts] = f.mock.calls[0]
|
||||||
|
expect(opts.headers['Authorization']).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears session on 401', async () => {
|
||||||
|
await saveSession('expired', { id: 1 })
|
||||||
|
mockFetch({}, 401)
|
||||||
|
await expect(apiFetch('/api/test')).rejects.toThrow('unauthorized')
|
||||||
|
expect(await db.session.get(1)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('apiJSON', () => {
|
||||||
|
it('parses JSON response', async () => {
|
||||||
|
mockFetch({ name: '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
49
frontend/src/db.test.js
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { db, getLastSync, setLastSync, getSession, saveSession, clearSession } from './db.js'
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await Promise.all(db.tables.map(t => t.clear()))
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('db schema', () => {
|
||||||
|
it('has expected tables', () => {
|
||||||
|
const names = db.tables.map(t => t.name).sort()
|
||||||
|
expect(names).toEqual([
|
||||||
|
'attendees', 'departments', 'event', 'meta',
|
||||||
|
'outbox', 'session', 'shifts', 'volunteer_shifts', 'volunteers',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('session', () => {
|
||||||
|
it('returns undefined when no session', async () => {
|
||||||
|
const s = await getSession()
|
||||||
|
expect(s).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves and retrieves session', async () => {
|
||||||
|
await saveSession('tok123', { id: 1, username: 'admin', role: 'admin' })
|
||||||
|
const s = await getSession()
|
||||||
|
expect(s.token).toBe('tok123')
|
||||||
|
expect(s.user.username).toBe('admin')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears session and meta', async () => {
|
||||||
|
await saveSession('tok123', { id: 1 })
|
||||||
|
await setLastSync('2026-01-01T00:00:00Z')
|
||||||
|
await clearSession()
|
||||||
|
expect(await getSession()).toBeUndefined()
|
||||||
|
expect(await getLastSync()).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('lastSync', () => {
|
||||||
|
it('returns empty string when unset', async () => {
|
||||||
|
expect(await getLastSync()).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('roundtrips a timestamp', async () => {
|
||||||
|
await setLastSync('2026-03-01T12:00:00Z')
|
||||||
|
expect(await getLastSync()).toBe('2026-03-01T12:00:00Z')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -40,6 +40,9 @@ export async function syncPull() {
|
||||||
}
|
}
|
||||||
if (data.volunteer_shifts?.length) {
|
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
88
frontend/src/sync.test.js
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { db, getLastSync, setLastSync } from './db.js'
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await Promise.all(db.tables.map(t => t.clear()))
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
function mockFetch(body = {}, status = 200) {
|
||||||
|
globalThis.fetch = vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
statusText: 'OK',
|
||||||
|
json: () => Promise.resolve(body),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('syncPull', () => {
|
||||||
|
it('writes attendees to Dexie', async () => {
|
||||||
|
mockFetch({
|
||||||
|
server_time: '2026-03-01T12:00:00Z',
|
||||||
|
attendees: [{ id: 1, name: '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')
|
||||||
|
})
|
||||||
|
})
|
||||||
1
frontend/src/test-setup.js
Normal file
1
frontend/src/test-setup.js
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
import 'fake-indexeddb/auto'
|
||||||
|
|
@ -12,4 +12,8 @@ export default defineConfig({
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
},
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./src/test-setup.js'],
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
109
handle_attendees_test.go
Normal file
109
handle_attendees_test.go
Normal 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
142
handle_import_test.go
Normal 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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
167
handle_kiosk_test.go
Normal 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
71
handle_settings_test.go
Normal 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
131
handle_shifts_test.go
Normal 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
110
handle_sync_test.go
Normal 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
91
testutil_test.go
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue