Implement Issue #2: Scheduling & Publishing
Replaces stub schedule CRUD with full shift template + instance system. - DB: add shift_templates, shift_template_roles, shift_template_volunteers, shift_instances, shift_instance_volunteers tables - schedule package: ShiftTemplate and ShiftInstance models with store (generate, publish/unpublish, per-instance edits, volunteer confirmation) - API: shift-templates CRUD + shifts generate/publish/unpublish/update/confirm - Notifications sent on publish (FR-S04), unpublish (FR-S05), instance edit (FR-S09), and volunteer added mid-month (FR-S10) - Frontend: Schedules page with month navigation, template management, publish/unpublish controls, and per-shift edit/confirm - Tests: Go handler tests (14 cases) + React tests (11 cases) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
476
internal/schedule/handler_test.go
Normal file
476
internal/schedule/handler_test.go
Normal file
@@ -0,0 +1,476 @@
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
Reference in New Issue
Block a user