From 5bdac7d275c3500b878a7f29521318c15b01b7d8 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Sun, 21 Jun 2026 21:47:35 +0100 Subject: [PATCH] feat(bookings): add trigger tests for booking computed fields Add test suite for PL/pgSQL trigger that auto-recalculates bookings.total_duration_minutes, bookings.total_amount, and bookings.end_time when booking_services or booking_custom_services change. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/handlers/bookings/trigger_test.go | 509 ++++++++++++++++++++++ 1 file changed, 509 insertions(+) create mode 100644 backend/handlers/bookings/trigger_test.go diff --git a/backend/handlers/bookings/trigger_test.go b/backend/handlers/bookings/trigger_test.go new file mode 100644 index 0000000..1fbeeb7 --- /dev/null +++ b/backend/handlers/bookings/trigger_test.go @@ -0,0 +1,509 @@ +//go:build test && dev +// +build test,dev + +package bookings + +import ( + "testing" + "time" + + "crussell/testutils" + "crussell/testutils/fixtures" +) + +// ============================================================================ +// Trigger: total_duration_minutes computation +// ============================================================================ + +func TestTrigger_TotalDuration_SingleService(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + svcID, err := fixtures.CreateTestServiceWithDuration(tx, 30) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, svcID, time.Date(2099, 6, 21, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + var duration int + if err := tx.QueryRow(ctx, `SELECT total_duration_minutes FROM bookings WHERE id = $1`, bookingID).Scan(&duration); err != nil { + t.Fatalf("failed to get total_duration_minutes: %v", err) + } + if duration != 30 { + t.Errorf("expected 30, got %d", duration) + } + + var amount float64 + if err := tx.QueryRow(ctx, `SELECT total_amount FROM bookings WHERE id = $1`, bookingID).Scan(&amount); err != nil { + t.Fatalf("failed to get total_amount: %v", err) + } + if amount != 50.00 { + t.Errorf("expected 50.00, got %.2f", amount) + } + + var endTime time.Time + if err := tx.QueryRow(ctx, `SELECT end_time FROM bookings WHERE id = $1`, bookingID).Scan(&endTime); err != nil { + t.Fatalf("failed to get end_time: %v", err) + } + expectedEnd := time.Date(2099, 6, 21, 10, 30, 0, 0, time.UTC) + if !endTime.Equal(expectedEnd) { + t.Errorf("expected end_time %v, got %v", expectedEnd, endTime) + } +} + +func TestTrigger_TotalDuration_MultipleServices(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + svc1ID, err := fixtures.CreateTestServiceWithDuration(tx, 30) + if err != nil { + t.Fatalf("failed to create service 1: %v", err) + } + svc2ID, err := fixtures.CreateTestServiceWithDuration(tx, 45) + if err != nil { + t.Fatalf("failed to create service 2: %v", err) + } + + // Create booking without fixture so we can control service insertion + var bookingID string + err = tx.QueryRow(ctx, ` + INSERT INTO bookings (user_id, start_time, status, notes) + VALUES ($1, $2, 'pending', 'multi-service test') + RETURNING id + `, userID, time.Date(2099, 6, 21, 10, 0, 0, 0, time.UTC)).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + // Add both services + for _, svcID := range []string{svc1ID, svc2ID} { + if _, err := tx.Exec(ctx, `INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)`, bookingID, svcID); err != nil { + t.Fatalf("failed to add service %s: %v", svcID, err) + } + } + + var duration int + if err := tx.QueryRow(ctx, `SELECT total_duration_minutes FROM bookings WHERE id = $1`, bookingID).Scan(&duration); err != nil { + t.Fatalf("failed to get total_duration_minutes: %v", err) + } + if duration != 75 { + t.Errorf("expected 75 (30+45), got %d", duration) + } +} + +func TestTrigger_TotalDuration_MixedRegularAndCustom(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + svcID, err := fixtures.CreateTestServiceWithDuration(tx, 30) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + csID, err := fixtures.CreateTestCustomService(tx) + if err != nil { + t.Fatalf("failed to create custom service: %v", err) + } + + var bookingID string + err = tx.QueryRow(ctx, ` + INSERT INTO bookings (user_id, start_time, status, notes) + VALUES ($1, $2, 'pending', 'mixed test') + RETURNING id + `, userID, time.Date(2099, 6, 21, 10, 0, 0, 0, time.UTC)).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + if _, err := tx.Exec(ctx, `INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)`, bookingID, svcID); err != nil { + t.Fatalf("failed to add service: %v", err) + } + if _, err := tx.Exec(ctx, `INSERT INTO booking_custom_services (booking_id, custom_service_id) VALUES ($1, $2)`, bookingID, csID); err != nil { + t.Fatalf("failed to add custom service: %v", err) + } + + var duration int + if err := tx.QueryRow(ctx, `SELECT total_duration_minutes FROM bookings WHERE id = $1`, bookingID).Scan(&duration); err != nil { + t.Fatalf("failed to get total_duration_minutes: %v", err) + } + // 30 (regular) + 45 (custom, from CreateTestCustomService) + if duration != 75 { + t.Errorf("expected 75 (30+45), got %d", duration) + } +} + +func TestTrigger_TotalDuration_OverrideDuration(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + // Service with 60min duration + svcID, err := fixtures.CreateTestServiceWithDuration(tx, 60) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + + var bookingID string + err = tx.QueryRow(ctx, ` + INSERT INTO bookings (user_id, start_time, status, notes) + VALUES ($1, $2, 'pending', 'override test') + RETURNING id + `, userID, time.Date(2099, 6, 21, 10, 0, 0, 0, time.UTC)).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + // Add with override_duration_minutes = 20 (not the service's 60) + if _, err := tx.Exec(ctx, `INSERT INTO booking_services (booking_id, service_id, override_duration_minutes) VALUES ($1, $2, 20)`, bookingID, svcID); err != nil { + t.Fatalf("failed to add service: %v", err) + } + + // Also add custom service with override + csID, err := fixtures.CreateTestCustomService(tx) + if err != nil { + t.Fatalf("failed to create custom service: %v", err) + } + if _, err := tx.Exec(ctx, `INSERT INTO booking_custom_services (booking_id, custom_service_id, override_duration_minutes) VALUES ($1, $2, 10)`, bookingID, csID); err != nil { + t.Fatalf("failed to add custom service: %v", err) + } + + var duration int + if err := tx.QueryRow(ctx, `SELECT total_duration_minutes FROM bookings WHERE id = $1`, bookingID).Scan(&duration); err != nil { + t.Fatalf("failed to get total_duration_minutes: %v", err) + } + if duration != 30 { + t.Errorf("expected 30 (20+10), got %d", duration) + } +} + +func TestTrigger_TotalDuration_DeleteService(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + svc1ID, err := fixtures.CreateTestServiceWithDuration(tx, 30) + if err != nil { + t.Fatalf("failed to create service 1: %v", err) + } + svc2ID, err := fixtures.CreateTestServiceWithDuration(tx, 45) + if err != nil { + t.Fatalf("failed to create service 2: %v", err) + } + + var bookingID string + err = tx.QueryRow(ctx, ` + INSERT INTO bookings (user_id, start_time, status, notes) + VALUES ($1, $2, 'pending', 'delete test') + RETURNING id + `, userID, time.Date(2099, 6, 21, 10, 0, 0, 0, time.UTC)).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + for _, svcID := range []string{svc1ID, svc2ID} { + if _, err := tx.Exec(ctx, `INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)`, bookingID, svcID); err != nil { + t.Fatalf("failed to add service: %v", err) + } + } + + // Delete one service + if _, err := tx.Exec(ctx, `DELETE FROM booking_services WHERE booking_id = $1 AND service_id = $2`, bookingID, svc1ID); err != nil { + t.Fatalf("failed to delete service: %v", err) + } + + var duration int + if err := tx.QueryRow(ctx, `SELECT total_duration_minutes FROM bookings WHERE id = $1`, bookingID).Scan(&duration); err != nil { + t.Fatalf("failed to get total_duration_minutes: %v", err) + } + if duration != 45 { + t.Errorf("expected 45 (remaining service), got %d", duration) + } +} + +func TestTrigger_TotalAmount_OverridePrice(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + // Service with price 50.00 + svcID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + + var bookingID string + err = tx.QueryRow(ctx, ` + INSERT INTO bookings (user_id, start_time, status, notes) + VALUES ($1, $2, 'pending', 'price test') + RETURNING id + `, userID, time.Date(2099, 6, 21, 10, 0, 0, 0, time.UTC)).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + // Add with override_price = 35.00 (not the service's 50.00) + if _, err := tx.Exec(ctx, `INSERT INTO booking_services (booking_id, service_id, override_price) VALUES ($1, $2, 35.00)`, bookingID, svcID); err != nil { + t.Fatalf("failed to add service: %v", err) + } + + var amount float64 + if err := tx.QueryRow(ctx, `SELECT total_amount FROM bookings WHERE id = $1`, bookingID).Scan(&amount); err != nil { + t.Fatalf("failed to get total_amount: %v", err) + } + if amount != 35.00 { + t.Errorf("expected 35.00, got %.2f", amount) + } +} + +func TestTrigger_DefaultValues_NoServices(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + startTime := time.Date(2099, 6, 21, 10, 0, 0, 0, time.UTC) + + var bookingID string + err = tx.QueryRow(ctx, ` + INSERT INTO bookings (user_id, start_time, status, notes) + VALUES ($1, $2, 'pending', 'no services test') + RETURNING id + `, userID, startTime).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + var duration int + if err := tx.QueryRow(ctx, `SELECT total_duration_minutes FROM bookings WHERE id = $1`, bookingID).Scan(&duration); err != nil { + t.Fatalf("failed to get total_duration_minutes: %v", err) + } + if duration != 60 { + t.Errorf("expected default 60, got %d", duration) + } + + var amount float64 + if err := tx.QueryRow(ctx, `SELECT total_amount FROM bookings WHERE id = $1`, bookingID).Scan(&amount); err != nil { + t.Fatalf("failed to get total_amount: %v", err) + } + if amount != 0 { + t.Errorf("expected default 0, got %.2f", amount) + } + + var endTime time.Time + if err := tx.QueryRow(ctx, `SELECT end_time FROM bookings WHERE id = $1`, bookingID).Scan(&endTime); err != nil { + t.Fatalf("failed to get end_time: %v", err) + } + expectedEnd := time.Date(2099, 6, 21, 11, 0, 0, 0, time.UTC) + if !endTime.Equal(expectedEnd) { + t.Errorf("expected end_time %v, got %v (from defaults 60min)", expectedEnd, endTime) + } +} + +func TestTrigger_UpdateBookingService_Override(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + svcID, err := fixtures.CreateTestServiceWithDuration(tx, 30) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, svcID, time.Date(2099, 6, 21, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + // Update the booking_service with an override + if _, err := tx.Exec(ctx, `UPDATE booking_services SET override_duration_minutes = 15, override_price = 25.00 WHERE booking_id = $1 AND service_id = $2`, bookingID, svcID); err != nil { + t.Fatalf("failed to update booking_service: %v", err) + } + + var duration int + if err := tx.QueryRow(ctx, `SELECT total_duration_minutes FROM bookings WHERE id = $1`, bookingID).Scan(&duration); err != nil { + t.Fatalf("failed to get total_duration_minutes: %v", err) + } + if duration != 15 { + t.Errorf("expected 15 (from override), got %d", duration) + } + + var amount float64 + if err := tx.QueryRow(ctx, `SELECT total_amount FROM bookings WHERE id = $1`, bookingID).Scan(&amount); err != nil { + t.Fatalf("failed to get total_amount: %v", err) + } + if amount != 25.00 { + t.Errorf("expected 25.00 (from override), got %.2f", amount) + } +} + +// ============================================================================ +// Overlap equivalence: old subquery vs new end_time column +// ============================================================================ + +// TestOverlap_Equivalence verifies that the new end_time-based overlap check +// returns identical results to the old subquery-based check across various +// overlap scenarios using isolated transactions per subtest. +func TestOverlap_Equivalence(t *testing.T) { + t.Parallel() + + type scenario struct { + name string + svcDuration int + existingStart time.Time + existingStatus string + probeStart time.Time + probeDuration int + expectOverlap bool + } + + base := time.Date(2099, 6, 21, 10, 0, 0, 0, time.UTC) + tests := []scenario{ + { + name: "exact match — probe starts when existing ends", + svcDuration: 60, + existingStart: base, + existingStatus: "confirmed", + probeStart: base.Add(60 * time.Minute), + probeDuration: 30, + expectOverlap: false, + }, + { + name: "partial overlap — probe starts before existing ends", + svcDuration: 60, + existingStart: base, + existingStatus: "confirmed", + probeStart: base.Add(30 * time.Minute), + probeDuration: 60, + expectOverlap: true, + }, + { + name: "complete overlap — probe fully inside existing", + svcDuration: 90, + existingStart: base, + existingStatus: "confirmed", + probeStart: base.Add(15 * time.Minute), + probeDuration: 30, + expectOverlap: true, + }, + { + name: "no overlap — different days", + svcDuration: 60, + existingStart: base, + existingStatus: "confirmed", + probeStart: base.Add(24 * time.Hour), + probeDuration: 30, + expectOverlap: false, + }, + { + name: "no overlap — probe ends before existing starts", + svcDuration: 60, + existingStart: base.Add(2 * time.Hour), + existingStatus: "confirmed", + probeStart: base, + probeDuration: 60, + expectOverlap: false, + }, + { + name: "ignores completed bookings (terminal status)", + svcDuration: 60, + existingStart: base, + existingStatus: "completed", + probeStart: base, + probeDuration: 30, + expectOverlap: false, + }, + { + name: "ignores cancelled bookings", + svcDuration: 60, + existingStart: base, + existingStatus: "client_cancelled", + probeStart: base, + probeDuration: 30, + expectOverlap: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + svcID, err := fixtures.CreateTestServiceWithDuration(tx, tc.svcDuration) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + + existingID, err := fixtures.CreateTestBookingAtTime(tx, userID, svcID, tc.existingStart) + if err != nil { + t.Fatalf("failed to create existing booking: %v", err) + } + // Override status to match scenario + if _, err := tx.Exec(ctx, `UPDATE bookings SET status = $1 WHERE id = $2`, tc.existingStatus, existingID); err != nil { + t.Fatalf("failed to set status: %v", err) + } + + probeEnd := tc.probeStart.Add(time.Duration(tc.probeDuration) * time.Minute) + + // Old method: subquery-based overlap check + var oldCount int + oldSQL := ` + SELECT COUNT(*) FROM bookings + WHERE status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed') + AND start_time < $2 + AND start_time + (INTERVAL '1 minute' * (SELECT COALESCE(SUM(dur), 60) FROM ( + SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur + FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = bookings.id + UNION ALL + SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes) + FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = bookings.id + ) sub)) > $1 + ` + if err := tx.QueryRow(ctx, oldSQL, tc.probeStart, probeEnd).Scan(&oldCount); err != nil { + t.Fatalf("old query failed: %v", err) + } + + // New method: end_time-based overlap check + var newCount int + newSQL := ` + SELECT COUNT(*) FROM bookings + WHERE status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed') + AND start_time < $2 + AND end_time > $1 + ` + if err := tx.QueryRow(ctx, newSQL, tc.probeStart, probeEnd).Scan(&newCount); err != nil { + t.Fatalf("new query failed: %v", err) + } + + if oldCount != newCount { + t.Errorf("count mismatch: old=%d new=%d (expectOverlap=%v)", oldCount, newCount, tc.expectOverlap) + } + + hasOverlap := newCount > 0 + if hasOverlap != tc.expectOverlap { + t.Errorf("overlap mismatch: got=%v expected=%v (newCount=%d)", hasOverlap, tc.expectOverlap, newCount) + } + }) + } +}