package schedule_test import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "testing" "git.unsupervised.ca/walkies/internal/auth" "git.unsupervised.ca/walkies/internal/schedule" "git.unsupervised.ca/walkies/internal/server/middleware" "github.com/go-chi/chi/v5" ) // --------------------------------------------------------------------------- // Fakes // --------------------------------------------------------------------------- type fakeStore struct { templates []schedule.ShiftTemplate instances []schedule.ShiftInstance createTmpl *schedule.ShiftTemplate createErr error updateTmpl *schedule.ShiftTemplate updateErr error deleteErr error genResult []schedule.ShiftInstance genErr error publishResult map[int64][]schedule.ShiftInstance publishErr error unpubResult []int64 unpubErr error updateInst *schedule.ShiftInstance addedVols []int64 updateInstErr error confirmErr error } func (f *fakeStore) CreateTemplate(_ context.Context, in schedule.CreateTemplateInput) (*schedule.ShiftTemplate, error) { if f.createErr != nil { return nil, f.createErr } if f.createTmpl != nil { return f.createTmpl, nil } return &schedule.ShiftTemplate{ID: 1, Name: in.Name, DayOfWeek: in.DayOfWeek, StartTime: in.StartTime, EndTime: in.EndTime, MinCapacity: in.MinCapacity, MaxCapacity: in.MaxCapacity, Roles: []schedule.TemplateRole{}, VolunteerIDs: []int64{}}, nil } func (f *fakeStore) GetTemplate(_ context.Context, id int64) (*schedule.ShiftTemplate, error) { for _, t := range f.templates { if t.ID == id { return &t, nil } } return nil, schedule.ErrNotFound } func (f *fakeStore) ListTemplates(_ context.Context) ([]schedule.ShiftTemplate, error) { return f.templates, nil } func (f *fakeStore) UpdateTemplate(_ context.Context, id int64, _ schedule.UpdateTemplateInput) (*schedule.ShiftTemplate, error) { if f.updateErr != nil { return nil, f.updateErr } if f.updateTmpl != nil { return f.updateTmpl, nil } for _, t := range f.templates { if t.ID == id { return &t, nil } } return nil, schedule.ErrNotFound } func (f *fakeStore) DeleteTemplate(_ context.Context, _ int64) error { return f.deleteErr } func (f *fakeStore) GenerateInstances(_ context.Context, _, _ int) ([]schedule.ShiftInstance, error) { return f.genResult, f.genErr } func (f *fakeStore) ListInstances(_ context.Context, _, _ int, _ int64) ([]schedule.ShiftInstance, error) { return f.instances, nil } func (f *fakeStore) GetInstance(_ context.Context, id int64) (*schedule.ShiftInstance, error) { for _, inst := range f.instances { if inst.ID == id { return &inst, nil } } return nil, schedule.ErrNotFound } func (f *fakeStore) UpdateInstance(_ context.Context, _ int64, _ schedule.UpdateInstanceInput) (*schedule.ShiftInstance, []int64, error) { return f.updateInst, f.addedVols, f.updateInstErr } func (f *fakeStore) PublishMonth(_ context.Context, _, _ int) (map[int64][]schedule.ShiftInstance, error) { return f.publishResult, f.publishErr } func (f *fakeStore) UnpublishMonth(_ context.Context, _, _ int) ([]int64, error) { return f.unpubResult, f.unpubErr } func (f *fakeStore) ConfirmShift(_ context.Context, _, _ int64) error { return f.confirmErr } type fakeNotifier struct { calls []struct { volunteerID int64 message string } } func (n *fakeNotifier) CreateNotification(_ context.Context, volunteerID int64, message string) error { n.calls = append(n.calls, struct { volunteerID int64 message string }{volunteerID, message}) return 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 *schedule.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/shift-templates", h.ListTemplates) r.Post("/api/v1/shift-templates", middleware.RequireAdmin(http.HandlerFunc(h.CreateTemplate)).ServeHTTP) r.Put("/api/v1/shift-templates/{id}", middleware.RequireAdmin(http.HandlerFunc(h.UpdateTemplate)).ServeHTTP) r.Delete("/api/v1/shift-templates/{id}", middleware.RequireAdmin(http.HandlerFunc(h.DeleteTemplate)).ServeHTTP) r.Get("/api/v1/shifts", h.ListInstances) r.Post("/api/v1/shifts/generate", middleware.RequireAdmin(http.HandlerFunc(h.GenerateInstances)).ServeHTTP) r.Post("/api/v1/shifts/publish", middleware.RequireAdmin(http.HandlerFunc(h.PublishMonth)).ServeHTTP) r.Post("/api/v1/shifts/unpublish", middleware.RequireAdmin(http.HandlerFunc(h.UnpublishMonth)).ServeHTTP) r.Put("/api/v1/shifts/{id}", middleware.RequireAdmin(http.HandlerFunc(h.UpdateInstance)).ServeHTTP) r.Post("/api/v1/shifts/{id}/confirm", h.ConfirmShift) }) 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 } // --------------------------------------------------------------------------- // Template tests // --------------------------------------------------------------------------- func TestListTemplates_ReturnsEmpty(t *testing.T) { store := &fakeStore{} notifier := &fakeNotifier{} h := schedule.NewHandlerFromInterfaces(store, notifier, nil) router := newRouter(h) token := jwtForRole(t, 1, "admin") w := do(t, router, "GET", "/api/v1/shift-templates", "", token) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) } var result []schedule.ShiftTemplate json.NewDecoder(w.Body).Decode(&result) if len(result) != 0 { t.Errorf("expected empty list, got %d items", len(result)) } } func TestCreateTemplate_AdminOnly(t *testing.T) { store := &fakeStore{} notifier := &fakeNotifier{} h := schedule.NewHandlerFromInterfaces(store, notifier, nil) router := newRouter(h) token := jwtForRole(t, 2, "volunteer") w := do(t, router, "POST", "/api/v1/shift-templates", `{"name":"Morning","day_of_week":1,"start_time":"09:00:00","end_time":"12:00:00","min_capacity":2,"max_capacity":5}`, token) if w.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d", w.Code) } } func TestCreateTemplate_Success(t *testing.T) { store := &fakeStore{} notifier := &fakeNotifier{} h := schedule.NewHandlerFromInterfaces(store, notifier, nil) router := newRouter(h) token := jwtForRole(t, 1, "admin") w := do(t, router, "POST", "/api/v1/shift-templates", `{"name":"Morning","day_of_week":1,"start_time":"09:00:00","end_time":"12:00:00","min_capacity":2,"max_capacity":5}`, token) if w.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", w.Code, w.Body) } var tmpl schedule.ShiftTemplate json.NewDecoder(w.Body).Decode(&tmpl) if tmpl.Name != "Morning" { t.Errorf("expected name Morning, got %q", tmpl.Name) } } func TestCreateTemplate_MissingFields(t *testing.T) { store := &fakeStore{} notifier := &fakeNotifier{} h := schedule.NewHandlerFromInterfaces(store, notifier, nil) router := newRouter(h) token := jwtForRole(t, 1, "admin") w := do(t, router, "POST", "/api/v1/shift-templates", `{"day_of_week":1}`, token) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestDeleteTemplate_AdminOnly(t *testing.T) { store := &fakeStore{} notifier := &fakeNotifier{} h := schedule.NewHandlerFromInterfaces(store, notifier, nil) router := newRouter(h) token := jwtForRole(t, 2, "volunteer") w := do(t, router, "DELETE", "/api/v1/shift-templates/1", "", token) if w.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d", w.Code) } } func TestDeleteTemplate_Success(t *testing.T) { store := &fakeStore{} notifier := &fakeNotifier{} h := schedule.NewHandlerFromInterfaces(store, notifier, nil) router := newRouter(h) token := jwtForRole(t, 1, "admin") w := do(t, router, "DELETE", "/api/v1/shift-templates/1", "", token) if w.Code != http.StatusNoContent { t.Fatalf("expected 204, got %d", w.Code) } } // --------------------------------------------------------------------------- // Instance tests // --------------------------------------------------------------------------- func TestGenerateInstances_AdminOnly(t *testing.T) { store := &fakeStore{} notifier := &fakeNotifier{} h := schedule.NewHandlerFromInterfaces(store, notifier, nil) router := newRouter(h) token := jwtForRole(t, 2, "volunteer") w := do(t, router, "POST", "/api/v1/shifts/generate", `{"year":2026,"month":4}`, token) if w.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d", w.Code) } } func TestGenerateInstances_AlreadyExists(t *testing.T) { store := &fakeStore{genErr: schedule.ErrAlreadyExists} notifier := &fakeNotifier{} h := schedule.NewHandlerFromInterfaces(store, notifier, nil) router := newRouter(h) token := jwtForRole(t, 1, "admin") w := do(t, router, "POST", "/api/v1/shifts/generate", `{"year":2026,"month":4}`, token) if w.Code != http.StatusConflict { t.Fatalf("expected 409, got %d: %s", w.Code, w.Body) } } func TestGenerateInstances_Success(t *testing.T) { store := &fakeStore{ genResult: []schedule.ShiftInstance{ {ID: 1, Name: "Morning", Date: "2026-04-06", Status: "draft", Volunteers: []schedule.InstanceVolunteer{}}, }, } notifier := &fakeNotifier{} h := schedule.NewHandlerFromInterfaces(store, notifier, nil) router := newRouter(h) token := jwtForRole(t, 1, "admin") w := do(t, router, "POST", "/api/v1/shifts/generate", `{"year":2026,"month":4}`, token) if w.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", w.Code, w.Body) } var result []schedule.ShiftInstance json.NewDecoder(w.Body).Decode(&result) if len(result) != 1 { t.Errorf("expected 1 instance, got %d", len(result)) } } func TestPublishMonth_SendsNotifications(t *testing.T) { store := &fakeStore{ publishResult: map[int64][]schedule.ShiftInstance{ 10: {{ID: 1, Name: "Morning", Date: "2026-04-06", Volunteers: []schedule.InstanceVolunteer{}}}, 20: {{ID: 2, Name: "Morning", Date: "2026-04-13", Volunteers: []schedule.InstanceVolunteer{}}}, }, } notifier := &fakeNotifier{} h := schedule.NewHandlerFromInterfaces(store, notifier, nil) router := newRouter(h) token := jwtForRole(t, 1, "admin") w := do(t, router, "POST", "/api/v1/shifts/publish", `{"year":2026,"month":4}`, token) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) } if len(notifier.calls) != 2 { t.Errorf("expected 2 notifications, got %d", len(notifier.calls)) } } func TestUnpublishMonth_SendsNotifications(t *testing.T) { store := &fakeStore{unpubResult: []int64{10, 20}} notifier := &fakeNotifier{} h := schedule.NewHandlerFromInterfaces(store, notifier, nil) router := newRouter(h) token := jwtForRole(t, 1, "admin") w := do(t, router, "POST", "/api/v1/shifts/unpublish", `{"year":2026,"month":4}`, token) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) } if len(notifier.calls) != 2 { t.Errorf("expected 2 notifications, got %d", len(notifier.calls)) } } func TestUpdateInstance_AdminOnly(t *testing.T) { store := &fakeStore{} notifier := &fakeNotifier{} h := schedule.NewHandlerFromInterfaces(store, notifier, nil) router := newRouter(h) token := jwtForRole(t, 2, "volunteer") w := do(t, router, "PUT", "/api/v1/shifts/1", `{"volunteer_ids":[5]}`, token) if w.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d", w.Code) } } func TestUpdateInstance_PublishedResetsAndNotifies(t *testing.T) { vids := []int64{5, 6} store := &fakeStore{ updateInst: &schedule.ShiftInstance{ ID: 1, Status: "published", Date: "2026-04-06", StartTime: "09:00:00", EndTime: "12:00:00", Volunteers: []schedule.InstanceVolunteer{ {InstanceID: 1, VolunteerID: 5, Name: "Alice"}, {InstanceID: 1, VolunteerID: 6, Name: "Bob"}, }, }, addedVols: []int64{6}, // Bob is newly added } notifier := &fakeNotifier{} h := schedule.NewHandlerFromInterfaces(store, notifier, nil) router := newRouter(h) token := jwtForRole(t, 1, "admin") body, _ := json.Marshal(map[string]any{"volunteer_ids": vids}) w := do(t, router, "PUT", "/api/v1/shifts/1", string(body), token) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) } // 2 notifications for existing volunteers (reset) + 1 for newly added // Total = 3 but FR-S10 is a subset of FR-S09, so we don't double-count // The handler sends update notices to all current volunteers (2) and // an "added" notice only to the newly added one (1) = 3 notifications. if len(notifier.calls) != 3 { t.Errorf("expected 3 notifications (2 reset + 1 added), got %d", len(notifier.calls)) } } func TestConfirmShift_NotAssigned(t *testing.T) { store := &fakeStore{confirmErr: schedule.ErrNotFound} notifier := &fakeNotifier{} h := schedule.NewHandlerFromInterfaces(store, notifier, nil) router := newRouter(h) token := jwtForRole(t, 5, "volunteer") w := do(t, router, "POST", "/api/v1/shifts/99/confirm", "", token) if w.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d", w.Code) } } func TestConfirmShift_Success(t *testing.T) { store := &fakeStore{} notifier := &fakeNotifier{} h := schedule.NewHandlerFromInterfaces(store, notifier, nil) router := newRouter(h) token := jwtForRole(t, 5, "volunteer") w := do(t, router, "POST", "/api/v1/shifts/1/confirm", "", token) if w.Code != http.StatusNoContent { t.Fatalf("expected 204, got %d", w.Code) } } // Compile-time interface check var _ schedule.Storer = (*fakeStore)(nil) var _ schedule.Notifier = (*fakeNotifier)(nil)