//go:build test package scheduling import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "net/url" "testing" "time" "crussell/db" "crussell/testutils" "crussell/testutils/fixtures" ) func resetContactTestData(t *testing.T) (context.Context, db.Querier) { t.Helper() return testutils.SetupTestTx(t) } func requestAvailability(t *testing.T, ctx context.Context, nowUTC string) *httptest.ResponseRecorder { t.Helper() handler := http.HandlerFunc(GetContactAvailability) u := &url.URL{Path: "/api/contact-availability"} if nowUTC != "" { u.RawQuery = "now=" + url.QueryEscape(nowUTC) } req := httptest.NewRequest("GET", u.String(), nil) req = req.WithContext(ctx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) return w } // utc returns an RFC3339 UTC timestamp for the given date and time. func utc(date, wallTime string) string { return fmt.Sprintf("%sT%s:00Z", date, wallTime) } func parseAvailabilityResponse(t *testing.T, body []byte) ContactAvailabilityResponse { t.Helper() var resp ContactAvailabilityResponse if err := json.Unmarshal(body, &resp); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } return resp } func createBooking(t *testing.T, ctx context.Context, tx db.Querier, startTime time.Time, durationMinutes int, status string) { t.Helper() endTime := startTime.Add(time.Duration(durationMinutes) * time.Minute) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } t.Cleanup(func() { fixtures.DeleteUser(tx, userID) }) _, err = tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status, total_duration_minutes, end_time) VALUES ($1, $2, $3, $4, $5) `, userID, startTime, status, durationMinutes, endTime) if err != nil { t.Fatalf("failed to create booking: %v", err) } } func createBlocker(t *testing.T, ctx context.Context, tx db.Querier, startTime time.Time, durationMinutes int) { t.Helper() _, err := tx.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description) VALUES ($1, $2, 'Test Blocker') `, startTime, durationMinutes) if err != nil { t.Fatalf("failed to create blocker: %v", err) } } // --- Tests --- func TestContactAvailability_Sleeping_Before8AM(t *testing.T) { t.Parallel() ctx, _ := resetContactTestData(t) w := requestAvailability(t, ctx, utc("2026-07-29", "06:30")) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } resp := parseAvailabilityResponse(t, w.Body.Bytes()) if resp.State != ContactSleeping { t.Errorf("expected sleeping, got %s", resp.State) } } func TestContactAvailability_Sleeping_After1030PM(t *testing.T) { t.Parallel() ctx, _ := resetContactTestData(t) w := requestAvailability(t, ctx, utc("2026-07-29", "22:01")) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } resp := parseAvailabilityResponse(t, w.Body.Bytes()) if resp.State != ContactSleeping { t.Errorf("expected sleeping, got %s", resp.State) } } func TestContactAvailability_Available(t *testing.T) { t.Parallel() ctx, _ := resetContactTestData(t) w := requestAvailability(t, ctx, utc("2026-07-29", "10:00")) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } resp := parseAvailabilityResponse(t, w.Body.Bytes()) if resp.State != ContactAvailable { t.Errorf("expected available, got %s", resp.State) } } func TestContactAvailability_WithClient(t *testing.T) { t.Parallel() ctx, tx := resetContactTestData(t) bookingStart := time.Date(2026, 7, 29, 9, 0, 0, 0, time.UTC) createBooking(t, ctx, tx, bookingStart, 60, "in_progress") w := requestAvailability(t, ctx, utc("2026-07-29", "09:30")) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } resp := parseAvailabilityResponse(t, w.Body.Bytes()) if resp.State != ContactWithClient { t.Errorf("expected with_client, got %s", resp.State) } } func TestContactAvailability_WithClient_BookingStartedBeforeToday(t *testing.T) { t.Parallel() ctx, tx := resetContactTestData(t) bookingStart := time.Date(2026, 7, 28, 6, 0, 0, 0, time.UTC) createBooking(t, ctx, tx, bookingStart, 1800, "in_progress") w := requestAvailability(t, ctx, utc("2026-07-29", "08:00")) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } resp := parseAvailabilityResponse(t, w.Body.Bytes()) if resp.State != ContactWithClient { t.Errorf("expected with_client for overnight booking, got %s", resp.State) } } func TestContactAvailability_Busy_Blocker(t *testing.T) { t.Parallel() ctx, tx := resetContactTestData(t) blockerStart := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) createBlocker(t, ctx, tx, blockerStart, 60) w := requestAvailability(t, ctx, utc("2026-07-29", "12:30")) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } resp := parseAvailabilityResponse(t, w.Body.Bytes()) if resp.State != ContactBusy { t.Errorf("expected busy, got %s", resp.State) } } func TestContactAvailability_Prepping_BeforeBooking(t *testing.T) { t.Parallel() ctx, tx := resetContactTestData(t) bookingStart := time.Date(2026, 7, 29, 10, 0, 0, 0, time.UTC) createBooking(t, ctx, tx, bookingStart, 60, "confirmed") w := requestAvailability(t, ctx, utc("2026-07-29", "09:57")) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } resp := parseAvailabilityResponse(t, w.Body.Bytes()) if resp.State != ContactPrepping { t.Errorf("expected prepping, got %s", resp.State) } } func TestContactAvailability_Prepping_AfterBookingEnds(t *testing.T) { t.Parallel() ctx, tx := resetContactTestData(t) bookingStart := time.Date(2026, 7, 29, 9, 0, 0, 0, time.UTC) createBooking(t, ctx, tx, bookingStart, 60, "completed") w := requestAvailability(t, ctx, utc("2026-07-29", "10:02")) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } resp := parseAvailabilityResponse(t, w.Body.Bytes()) if resp.State != ContactPrepping { t.Errorf("expected prepping, got %s", resp.State) } } func TestContactAvailability_Prepping_AfterBooking_ExactBoundary(t *testing.T) { t.Parallel() ctx, tx := resetContactTestData(t) bookingStart := time.Date(2026, 7, 29, 9, 0, 0, 0, time.UTC) createBooking(t, ctx, tx, bookingStart, 60, "completed") w := requestAvailability(t, ctx, utc("2026-07-29", "10:00")) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } resp := parseAvailabilityResponse(t, w.Body.Bytes()) if resp.State != ContactPrepping { t.Errorf("expected prepping (cleanup starts exactly at end), got %s", resp.State) } } func TestContactAvailability_Prepping_PriorityOverAvailable(t *testing.T) { t.Parallel() ctx, tx := resetContactTestData(t) bookingStart := time.Date(2026, 7, 29, 10, 0, 0, 0, time.UTC) createBooking(t, ctx, tx, bookingStart, 60, "confirmed") w := requestAvailability(t, ctx, utc("2026-07-29", "09:56")) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } resp := parseAvailabilityResponse(t, w.Body.Bytes()) if resp.State != ContactPrepping { t.Errorf("expected prepping, got %s", resp.State) } } func TestContactAvailability_BlockerPriorityOverPrepping(t *testing.T) { t.Parallel() ctx, tx := resetContactTestData(t) blockerStart := time.Date(2026, 7, 29, 9, 55, 0, 0, time.UTC) createBlocker(t, ctx, tx, blockerStart, 10) bookingStart := time.Date(2026, 7, 29, 10, 0, 0, 0, time.UTC) createBooking(t, ctx, tx, bookingStart, 60, "confirmed") w := requestAvailability(t, ctx, utc("2026-07-29", "09:58")) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } resp := parseAvailabilityResponse(t, w.Body.Bytes()) if resp.State != ContactBusy { t.Errorf("expected busy (blocker has priority), got %s", resp.State) } } func TestContactAvailability_WithClientPriorityOverBlocker(t *testing.T) { t.Parallel() ctx, tx := resetContactTestData(t) blockerStart := time.Date(2026, 7, 29, 9, 30, 0, 0, time.UTC) createBlocker(t, ctx, tx, blockerStart, 60) bookingStart := time.Date(2026, 7, 29, 9, 0, 0, 0, time.UTC) createBooking(t, ctx, tx, bookingStart, 60, "in_progress") w := requestAvailability(t, ctx, utc("2026-07-29", "09:30")) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } resp := parseAvailabilityResponse(t, w.Body.Bytes()) if resp.State != ContactWithClient { t.Errorf("expected with_client (higher priority than blocker), got %s", resp.State) } } func TestContactAvailability_NotPrepping_Before5MinWindow(t *testing.T) { t.Parallel() ctx, tx := resetContactTestData(t) bookingStart := time.Date(2026, 7, 29, 10, 0, 0, 0, time.UTC) createBooking(t, ctx, tx, bookingStart, 60, "confirmed") w := requestAvailability(t, ctx, utc("2026-07-29", "09:54")) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } resp := parseAvailabilityResponse(t, w.Body.Bytes()) if resp.State != ContactAvailable { t.Errorf("expected available (6 min before booking, outside prepping window), got %s", resp.State) } } func TestContactAvailability_NotPrepping_After5MinWindow(t *testing.T) { t.Parallel() ctx, tx := resetContactTestData(t) bookingStart := time.Date(2026, 7, 29, 9, 0, 0, 0, time.UTC) createBooking(t, ctx, tx, bookingStart, 60, "completed") w := requestAvailability(t, ctx, utc("2026-07-29", "10:06")) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } resp := parseAvailabilityResponse(t, w.Body.Bytes()) if resp.State != ContactAvailable { t.Errorf("expected available (6 min after booking, outside cleanup window), got %s", resp.State) } } func TestContactAvailability_InvalidNow(t *testing.T) { t.Parallel() ctx, _ := resetContactTestData(t) w := requestAvailability(t, ctx, "not-a-date") if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for invalid now parameter, got %d: %s", w.Code, w.Body.String()) } } func TestContactAvailability_Sleeping_Priority(t *testing.T) { t.Parallel() ctx, tx := resetContactTestData(t) bookingStart := time.Date(2026, 7, 29, 9, 0, 0, 0, time.UTC) createBooking(t, ctx, tx, bookingStart, 60, "in_progress") w := requestAvailability(t, ctx, utc("2026-07-29", "22:01")) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } resp := parseAvailabilityResponse(t, w.Body.Bytes()) if resp.State != ContactSleeping { t.Errorf("expected sleeping (highest priority), got %s", resp.State) } } func TestContactAvailability_NotSleeping_Exactly8AM(t *testing.T) { t.Parallel() ctx, _ := resetContactTestData(t) w := requestAvailability(t, ctx, utc("2026-07-29", "07:00")) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } resp := parseAvailabilityResponse(t, w.Body.Bytes()) if resp.State == ContactSleeping { t.Errorf("should not be sleeping at 08:00 BST (07:00 UTC)") } if resp.State != ContactAvailable { t.Errorf("expected available at 08:00 BST, got %s", resp.State) } } func TestContactAvailability_NotSleeping_Exactly1030PM(t *testing.T) { t.Parallel() ctx, _ := resetContactTestData(t) w := requestAvailability(t, ctx, utc("2026-07-29", "21:29")) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } resp := parseAvailabilityResponse(t, w.Body.Bytes()) if resp.State == ContactSleeping { t.Errorf("should not be sleeping at 22:29 BST (21:29 UTC)") } if resp.State != ContactAvailable { t.Errorf("expected available at 22:29 BST, got %s", resp.State) } } func TestContactAvailability_MultipleBookings_ActiveTakesPriority(t *testing.T) { t.Parallel() ctx, tx := resetContactTestData(t) createBooking(t, ctx, tx, time.Date(2026, 7, 29, 8, 0, 0, 0, time.UTC), 60, "completed") createBooking(t, ctx, tx, time.Date(2026, 7, 29, 9, 0, 0, 0, time.UTC), 60, "in_progress") createBooking(t, ctx, tx, time.Date(2026, 7, 29, 10, 0, 0, 0, time.UTC), 60, "confirmed") w := requestAvailability(t, ctx, utc("2026-07-29", "09:30")) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } resp := parseAvailabilityResponse(t, w.Body.Bytes()) if resp.State != ContactWithClient { t.Errorf("expected with_client for active booking among multiple, got %s", resp.State) } }