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>
This commit is contained in:
521
internal/timeoff/handler_test.go
Normal file
521
internal/timeoff/handler_test.go
Normal file
@@ -0,0 +1,521 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user