Files
walkies/internal/schedule/handler_test.go
James Griffin 07f11fa94e Implement time off management (Issue #3)
Add full time-off lifecycle: create/edit/delete with shift conflict
detection, auto-removal from conflicting shifts with admin notification,
shift restoration on admin delete, and hard block on assigning volunteers
with approved time off to shifts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 10:54:12 -03:00

477 lines
14 KiB
Go

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)