feat: add contact-availability handler and route
This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user