package timeoff_test import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "git.unsupervised.ca/walkies/internal/auth" "git.unsupervised.ca/walkies/internal/server/middleware" "git.unsupervised.ca/walkies/internal/timeoff" "github.com/go-chi/chi/v5" ) // --------------------------------------------------------------------------- // Fakes // --------------------------------------------------------------------------- type fakeStore struct { requests []timeoff.Request createResult *timeoff.Request createErr error getResult *timeoff.Request getErr error updateResult *timeoff.Request updateErr error deleteErr error reviewResult *timeoff.Request reviewErr error conflicts []timeoff.ConflictingShift conflictsErr error removedShifts []timeoff.ConflictingShift removeErr error removedForTimeOff []timeoff.ConflictingShift removedForErr error restoredShifts []timeoff.ConflictingShift restoreErr error removeCalled bool restoreCalled bool } func (f *fakeStore) Create(_ context.Context, volunteerID int64, in timeoff.CreateInput) (*timeoff.Request, error) { if f.createErr != nil { return nil, f.createErr } if f.createResult != nil { return f.createResult, nil } return &timeoff.Request{ ID: 1, VolunteerID: volunteerID, StartsAt: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), EndsAt: time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC), Status: "pending", }, nil } func (f *fakeStore) GetByID(_ context.Context, id int64) (*timeoff.Request, error) { if f.getErr != nil { return nil, f.getErr } if f.getResult != nil { return f.getResult, nil } return nil, timeoff.ErrNotFound } func (f *fakeStore) List(_ context.Context, volunteerID int64) ([]timeoff.Request, error) { if volunteerID > 0 { var filtered []timeoff.Request for _, r := range f.requests { if r.VolunteerID == volunteerID { filtered = append(filtered, r) } } return filtered, nil } return f.requests, nil } func (f *fakeStore) Review(_ context.Context, id, reviewerID int64, status string) (*timeoff.Request, error) { if f.reviewErr != nil { return nil, f.reviewErr } if f.reviewResult != nil { return f.reviewResult, nil } return &timeoff.Request{ID: id, Status: status}, nil } func (f *fakeStore) Update(_ context.Context, id int64, in timeoff.UpdateInput) (*timeoff.Request, error) { if f.updateErr != nil { return nil, f.updateErr } if f.updateResult != nil { return f.updateResult, nil } return &timeoff.Request{ID: id, Status: "approved"}, nil } func (f *fakeStore) Delete(_ context.Context, id int64) error { return f.deleteErr } func (f *fakeStore) ConflictingShifts(_ context.Context, _ int64, _, _ string) ([]timeoff.ConflictingShift, error) { if f.conflictsErr != nil { return nil, f.conflictsErr } return f.conflicts, nil } func (f *fakeStore) RemoveFromConflictingShifts(_ context.Context, _, _ int64, _, _ string) ([]timeoff.ConflictingShift, error) { f.removeCalled = true if f.removeErr != nil { return nil, f.removeErr } return f.removedShifts, nil } func (f *fakeStore) RemovedShiftsForTimeOff(_ context.Context, _ int64) ([]timeoff.ConflictingShift, error) { if f.removedForErr != nil { return nil, f.removedForErr } return f.removedForTimeOff, nil } func (f *fakeStore) RestoreRemovedShifts(_ context.Context, _ int64) ([]timeoff.ConflictingShift, error) { f.restoreCalled = true if f.restoreErr != nil { return nil, f.restoreErr } return f.restoredShifts, nil } type fakeNotifier struct { notifications []struct { VolunteerID int64 Message string } } func (f *fakeNotifier) CreateNotification(_ context.Context, volunteerID int64, message string) error { f.notifications = append(f.notifications, struct { VolunteerID int64 Message string }{volunteerID, message}) return nil } type fakeAdminLister struct { adminIDs []int64 } func (f *fakeAdminLister) ListAdminIDs(_ context.Context) ([]int64, error) { return f.adminIDs, nil } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- func jwtForRole(t *testing.T, id int64, role string) string { t.Helper() svc := auth.NewService(nil, "test-secret") token, err := svc.IssueToken(id, role) if err != nil { t.Fatalf("issue token: %v", err) } return token } func newRouter(h *timeoff.Handler) http.Handler { realAuthSvc := auth.NewService(nil, "test-secret") r := chi.NewRouter() r.Group(func(r chi.Router) { r.Use(middleware.Authenticate(realAuthSvc)) r.Get("/api/v1/timeoff", h.List) r.Post("/api/v1/timeoff", h.Create) r.Put("/api/v1/timeoff/{id}", h.Update) r.Delete("/api/v1/timeoff/{id}", h.Delete) r.Put("/api/v1/timeoff/{id}/review", middleware.RequireAdmin(http.HandlerFunc(h.Review)).ServeHTTP) r.Get("/api/v1/timeoff/{id}/shifts", middleware.RequireAdmin(http.HandlerFunc(h.RemovedShifts)).ServeHTTP) }) return r } func do(t *testing.T, router http.Handler, method, path, body, token string) *httptest.ResponseRecorder { t.Helper() var b *bytes.Reader if body != "" { b = bytes.NewReader([]byte(body)) } else { b = bytes.NewReader(nil) } req := httptest.NewRequest(method, path, b) if body != "" { req.Header.Set("Content-Type", "application/json") } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } w := httptest.NewRecorder() router.ServeHTTP(w, req) return w } func setup(store *fakeStore) (*timeoff.Handler, http.Handler) { notifier := &fakeNotifier{} adminLister := &fakeAdminLister{adminIDs: []int64{1}} h := timeoff.NewHandlerFromInterfaces(store, notifier, adminLister) return h, newRouter(h) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- func TestList_VolunteerSeesOwn(t *testing.T) { store := &fakeStore{ requests: []timeoff.Request{ {ID: 1, VolunteerID: 10, Status: "approved"}, {ID: 2, VolunteerID: 20, Status: "pending"}, }, } _, router := setup(store) token := jwtForRole(t, 10, "volunteer") w := do(t, router, "GET", "/api/v1/timeoff", "", token) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) } var result []timeoff.Request json.NewDecoder(w.Body).Decode(&result) if len(result) != 1 { t.Fatalf("expected 1 request, got %d", len(result)) } if result[0].VolunteerID != 10 { t.Errorf("expected volunteer_id=10, got %d", result[0].VolunteerID) } } func TestList_AdminSeesAll(t *testing.T) { store := &fakeStore{ requests: []timeoff.Request{ {ID: 1, VolunteerID: 10, Status: "approved"}, {ID: 2, VolunteerID: 20, Status: "pending"}, }, } _, router := setup(store) token := jwtForRole(t, 1, "admin") w := do(t, router, "GET", "/api/v1/timeoff", "", token) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var result []timeoff.Request json.NewDecoder(w.Body).Decode(&result) if len(result) != 2 { t.Errorf("expected 2 requests, got %d", len(result)) } } func TestCreate_NoConflicts(t *testing.T) { store := &fakeStore{} _, router := setup(store) token := jwtForRole(t, 10, "volunteer") body := `{"starts_at":"2026-05-01","ends_at":"2026-05-03","reason":"vacation"}` w := do(t, router, "POST", "/api/v1/timeoff", body, token) if w.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", w.Code, w.Body) } } func TestCreate_ConflictReturns409(t *testing.T) { store := &fakeStore{ conflicts: []timeoff.ConflictingShift{ {InstanceID: 100, Name: "Morning Walk", Date: "2026-05-01", StartTime: "08:00:00", EndTime: "12:00:00"}, }, } _, router := setup(store) token := jwtForRole(t, 10, "volunteer") body := `{"starts_at":"2026-05-01","ends_at":"2026-05-03"}` w := do(t, router, "POST", "/api/v1/timeoff", body, token) if w.Code != http.StatusConflict { t.Fatalf("expected 409, got %d: %s", w.Code, w.Body) } var result map[string]any json.NewDecoder(w.Body).Decode(&result) conflicts := result["conflicts"].([]any) if len(conflicts) != 1 { t.Errorf("expected 1 conflict, got %d", len(conflicts)) } } func TestCreate_ConfirmConflictsProceeds(t *testing.T) { store := &fakeStore{ conflicts: []timeoff.ConflictingShift{ {InstanceID: 100, Name: "Morning Walk", Date: "2026-05-01"}, }, removedShifts: []timeoff.ConflictingShift{ {InstanceID: 100, Name: "Morning Walk", Date: "2026-05-01"}, }, } _, router := setup(store) token := jwtForRole(t, 10, "volunteer") body := `{"starts_at":"2026-05-01","ends_at":"2026-05-03","confirm_conflicts":true}` w := do(t, router, "POST", "/api/v1/timeoff", body, token) if w.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", w.Code, w.Body) } if !store.removeCalled { t.Error("expected RemoveFromConflictingShifts to be called") } } func TestCreate_AdminForOtherVolunteer(t *testing.T) { store := &fakeStore{} _, router := setup(store) token := jwtForRole(t, 1, "admin") body := `{"starts_at":"2026-05-01","ends_at":"2026-05-03","volunteer_id":20}` w := do(t, router, "POST", "/api/v1/timeoff", body, token) if w.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", w.Code, w.Body) } } func TestUpdate_VolunteerOwnFuture(t *testing.T) { store := &fakeStore{ getResult: &timeoff.Request{ ID: 1, VolunteerID: 10, Status: "approved", StartsAt: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), EndsAt: time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC), }, } _, router := setup(store) token := jwtForRole(t, 10, "volunteer") body := `{"starts_at":"2026-05-02","ends_at":"2026-05-04","reason":"extended"}` w := do(t, router, "PUT", "/api/v1/timeoff/1", body, token) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) } } func TestUpdate_VolunteerCannotEditOthers(t *testing.T) { store := &fakeStore{ getResult: &timeoff.Request{ ID: 1, VolunteerID: 20, Status: "approved", StartsAt: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), }, } _, router := setup(store) token := jwtForRole(t, 10, "volunteer") body := `{"starts_at":"2026-05-02","ends_at":"2026-05-04"}` w := do(t, router, "PUT", "/api/v1/timeoff/1", body, token) if w.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d: %s", w.Code, w.Body) } } func TestUpdate_VolunteerCannotEditPast(t *testing.T) { store := &fakeStore{ getResult: &timeoff.Request{ ID: 1, VolunteerID: 10, Status: "approved", StartsAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), }, } _, router := setup(store) token := jwtForRole(t, 10, "volunteer") body := `{"starts_at":"2026-05-02","ends_at":"2026-05-04"}` w := do(t, router, "PUT", "/api/v1/timeoff/1", body, token) if w.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d: %s", w.Code, w.Body) } } func TestDelete_VolunteerOwnFuture(t *testing.T) { store := &fakeStore{ getResult: &timeoff.Request{ ID: 1, VolunteerID: 10, Status: "approved", StartsAt: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), }, } _, router := setup(store) token := jwtForRole(t, 10, "volunteer") w := do(t, router, "DELETE", "/api/v1/timeoff/1", "", token) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) } } func TestDelete_VolunteerCannotDeleteOthers(t *testing.T) { store := &fakeStore{ getResult: &timeoff.Request{ ID: 1, VolunteerID: 20, Status: "approved", StartsAt: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), }, } _, router := setup(store) token := jwtForRole(t, 10, "volunteer") w := do(t, router, "DELETE", "/api/v1/timeoff/1", "", token) if w.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d: %s", w.Code, w.Body) } } func TestDelete_AdminRestoresShifts(t *testing.T) { store := &fakeStore{ getResult: &timeoff.Request{ ID: 1, VolunteerID: 10, Status: "approved", StartsAt: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), }, restoredShifts: []timeoff.ConflictingShift{ {InstanceID: 100, Name: "Morning Walk", Date: "2026-05-01"}, }, } _, router := setup(store) token := jwtForRole(t, 1, "admin") w := do(t, router, "DELETE", "/api/v1/timeoff/1", "", token) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) } if !store.restoreCalled { t.Error("expected RestoreRemovedShifts to be called") } var result map[string]any json.NewDecoder(w.Body).Decode(&result) restored := result["restored_shifts"].([]any) if len(restored) != 1 { t.Errorf("expected 1 restored shift, got %d", len(restored)) } } func TestRemovedShifts_AdminOnly(t *testing.T) { store := &fakeStore{ removedForTimeOff: []timeoff.ConflictingShift{ {InstanceID: 100, Name: "Morning Walk", Date: "2026-05-01"}, }, } _, router := setup(store) // Volunteer should get 403 volToken := jwtForRole(t, 10, "volunteer") w := do(t, router, "GET", "/api/v1/timeoff/1/shifts", "", volToken) if w.Code != http.StatusForbidden { t.Fatalf("expected 403 for volunteer, got %d", w.Code) } // Admin should get 200 adminToken := jwtForRole(t, 1, "admin") w = do(t, router, "GET", "/api/v1/timeoff/1/shifts", "", adminToken) if w.Code != http.StatusOK { t.Fatalf("expected 200 for admin, got %d: %s", w.Code, w.Body) } var shifts []timeoff.ConflictingShift json.NewDecoder(w.Body).Decode(&shifts) if len(shifts) != 1 { t.Errorf("expected 1 shift, got %d", len(shifts)) } } func TestReview_AdminOnly(t *testing.T) { store := &fakeStore{} _, router := setup(store) volToken := jwtForRole(t, 10, "volunteer") body := `{"status":"approved"}` w := do(t, router, "PUT", "/api/v1/timeoff/1/review", body, volToken) if w.Code != http.StatusForbidden { t.Fatalf("expected 403 for volunteer, got %d", w.Code) } } func TestReview_InvalidStatus(t *testing.T) { store := &fakeStore{} _, router := setup(store) adminToken := jwtForRole(t, 1, "admin") body := `{"status":"maybe"}` w := do(t, router, "PUT", "/api/v1/timeoff/1/review", body, adminToken) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d: %s", w.Code, w.Body) } } func TestCreate_MissingDates(t *testing.T) { store := &fakeStore{} _, router := setup(store) token := jwtForRole(t, 10, "volunteer") body := `{"reason":"vacation"}` w := do(t, router, "POST", "/api/v1/timeoff", body, token) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } }