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>
477 lines
14 KiB
Go
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)
|