CI / Go vulnerabilities (push) Successful in 1m10s
CI / Build & Vet (push) Successful in 1m39s
CI / Frontend build (gate) (push) Successful in 1m42s
CI / Frontend QC (audit) (push) Successful in 56s
CI / Frontend QC (typecheck) (push) Successful in 1m36s
CI / Frontend QC (lint) (push) Successful in 1m51s
CI / Tests (prod) (push) Has been cancelled
CI / Tests (dev) (push) Has been cancelled
CI / Race (prod) (push) Has been cancelled
CI / Race (dev) (push) Has been cancelled
106 files: interface{}→any, strings.Split→SplitSeq, CutPrefix/Cut, strings.Builder, slices.Contains, remove redundant // +build directives, gofmt import ordering and indentation.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
509 lines
17 KiB
Go
509 lines
17 KiB
Go
//go: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)
|
|
}
|
|
})
|
|
}
|
|
}
|