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