feat(backend): add booking dedup, deposit tests and test main

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-18 16:26:24 +01:00
co-authored by Sisyphus
parent c805fea7df
commit e1f51420f7
3 changed files with 1872 additions and 4 deletions
+287
View File
@@ -0,0 +1,287 @@
//go:build test && dev
// +build test,dev
package bookings
import (
"context"
"testing"
"time"
"crussell/db"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// =============================================================================
// ProgressBookingHandler — Dedup guards for early-payment discounts
// =============================================================================
func setupDedupTest(t *testing.T) (string, string, string) {
t.Helper()
seedDefaultWorkingHours(t)
userID := createTestUser(t, 0)
serviceID := createTestService(t, 50.00)
now := time.Now()
startTime := now.Add(72 * time.Hour)
bookingID := createPendingBooking(t, userID, serviceID, startTime)
return userID, serviceID, bookingID
}
func insertPaymentForBooking(t *testing.T, bookingID, userID string, amount int) {
t.Helper()
_, err := db.DB.Exec(context.Background(), `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
VALUES ($1, 'full', 'cash', $2, 'completed', NOW(), NOW())
`, bookingID, amount)
require.NoError(t, err)
}
func TestProgressBooking_LoyaltyDedup(t *testing.T) {
resetTestData(t)
userID, _, bookingID := setupDedupTest(t)
// Pre-apply loyalty discount (simulating early-payment)
_, err := db.DB.Exec(context.Background(), `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'loyalty', 10, 5000, 500)
`, bookingID, userID)
require.NoError(t, err)
insertPaymentForBooking(t, bookingID, userID, 5000)
// Complete the booking — should not double-apply loyalty
completeBooking(t, bookingID)
count := getDiscountRowCount(t, bookingID)
assert.Equal(t, 1, count, "Loyalty should not be double-applied at completion")
}
func TestProgressBooking_TimeBasedCampaignDedup(t *testing.T) {
resetTestData(t)
userID, _, bookingID := setupDedupTest(t)
// Create a time-based campaign
campaignName := "Time Dedup Test"
campaign := createTestCampaign(t, campaignName, "time_based", 10.0, nil, nil, nil, nil)
// Pre-apply the campaign (simulating early-payment auto-apply)
_, err := db.DB.Exec(context.Background(), `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'time_based', 10, 5000, 500)
`, bookingID, userID, campaign)
require.NoError(t, err)
// Increment campaign's times_redeemed as if payment-time applied it
_, err = db.DB.Exec(context.Background(), `
UPDATE discount_campaigns SET times_redeemed = 1 WHERE id = $1
`, campaign)
require.NoError(t, err)
insertPaymentForBooking(t, bookingID, userID, 5000)
// Complete the booking
completeBooking(t, bookingID)
count := getDiscountRowCount(t, bookingID)
assert.Equal(t, 1, count, "Time-based campaign should not be double-applied at completion")
}
func TestProgressBooking_UserMilestoneDedup(t *testing.T) {
resetTestData(t)
userID, _, bookingID := setupDedupTest(t)
// Give user 5 completed bookings
svcID := createTestService(t, 30.00)
for i := 0; i < 5; i++ {
bid := createPendingBooking(t, userID, svcID, time.Now().Add(-time.Duration(30-i)*24*time.Hour))
insertPaymentForBooking(t, bid, userID, 3000)
completeBooking(t, bid)
}
val := 5
maxRed := 1
campaign := createTestCampaign(t, "5th Visit", "milestone", 15.0, strPtr("per_user_booking_count"), strPtr("bookings"), &val, &maxRed)
// Pre-apply the milestone (simulating early-payment)
_, err := db.DB.Exec(context.Background(), `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'milestone', 15, 5000, 750)
`, bookingID, userID, campaign)
require.NoError(t, err)
// Mark the campaign as already having 1 redemption
_, err = db.DB.Exec(context.Background(), `
UPDATE discount_campaigns SET times_redeemed = 1 WHERE id = $1
`, campaign)
require.NoError(t, err)
insertPaymentForBooking(t, bookingID, userID, 5000)
completeBooking(t, bookingID)
count := getDiscountRowCount(t, bookingID)
assert.Equal(t, 1, count, "User milestone should not be double-applied at completion")
}
// =============================================================================
// No-Show Tracking — ApplyDepositsIfNeeded at cancellation time
// =============================================================================
func TestNoShowApplyDepositsIfNeeded(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
userID := createTestUser(t, 0)
serviceID := createTestService(t, 50.00)
// Create 2 no-show bookings (confirmed bookings cancelled <24h before start)
now := time.Now()
for i := 0; i < 2; i++ {
bid := createPendingBooking(t, userID, serviceID, now.Add(-time.Duration(i)*time.Hour))
insertPaymentForBooking(t, bid, userID, 5000)
_, err := db.DB.Exec(context.Background(), `
UPDATE bookings SET status = 'no_show' WHERE id = $1
`, bid)
require.NoError(t, err)
}
// Run ApplyDepositsIfNeeded
applied, err := ApplyDepositsIfNeeded(context.Background(), userID)
require.NoError(t, err)
assert.True(t, applied, "Should have applied deposits_required = 3 after 2 no-shows")
var depositsRequired int
db.DB.QueryRow(context.Background(),
"SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&depositsRequired)
assert.Equal(t, 3, depositsRequired, "Expected deposits_required = 3 after 2 no-shows in 6 months")
}
func TestNoShowSingleNoShowDoesNotTrigger(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
userID := createTestUser(t, 0)
serviceID := createTestService(t, 50.00)
bid := createPendingBooking(t, userID, serviceID, time.Now().Add(-time.Hour))
insertPaymentForBooking(t, bid, userID, 5000)
_, err := db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", bid)
require.NoError(t, err)
applied, err := ApplyDepositsIfNeeded(context.Background(), userID)
require.NoError(t, err)
assert.False(t, applied, "Single no-show should not trigger deposits_required")
var depositsRequired int
db.DB.QueryRow(context.Background(),
"SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&depositsRequired)
assert.Equal(t, 0, depositsRequired, "Expected deposits_required = 0 with only 1 no-show")
}
func TestNoShowOldNoShowsExcluded(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
userID := createTestUser(t, 0)
serviceID := createTestService(t, 50.00)
// Create a no-show more than 6 months ago — should not count
oldBid := createPendingBooking(t, userID, serviceID, time.Now().Add(-200*24*time.Hour))
insertPaymentForBooking(t, oldBid, userID, 5000)
_, err := db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", oldBid)
require.NoError(t, err)
// Create a recent no-show (within 6 months)
recentBid := createPendingBooking(t, userID, serviceID, time.Now().Add(-time.Hour))
insertPaymentForBooking(t, recentBid, userID, 5000)
_, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", recentBid)
require.NoError(t, err)
// Should only count 1 recent no-show, not trigger
applied, err := ApplyDepositsIfNeeded(context.Background(), userID)
require.NoError(t, err)
assert.False(t, applied, "1 old + 1 recent = 2 total but only 1 in 6-month window")
}
func TestNoShowForgivenExcluded(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
userID := createTestUser(t, 0)
serviceID := createTestService(t, 50.00)
for i := 0; i < 2; i++ {
bid := createPendingBooking(t, userID, serviceID, time.Now().Add(-time.Duration(i)*time.Hour))
insertPaymentForBooking(t, bid, userID, 5000)
_, err := db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", bid)
require.NoError(t, err)
// Forgive the first one
if i == 0 {
_, err = db.DB.Exec(context.Background(), "INSERT INTO forgiven_no_shows (booking_id) VALUES ($1)", bid)
require.NoError(t, err)
}
}
applied, err := ApplyDepositsIfNeeded(context.Background(), userID)
require.NoError(t, err)
assert.False(t, applied, "1 forgiven + 1 unforgiven = should not trigger")
}
// =============================================================================
// Discount + 3 paid bookings → no-show records cleared
// =============================================================================
func TestThreePaidBookingsClearNoShows(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
userID := createTestUser(t, 0)
serviceID := createTestService(t, 50.00)
// Set deposits_required to 3 (simulating after 2 no-shows triggered it)
_, err := db.DB.Exec(context.Background(),
"UPDATE users SET deposits_required = 3 WHERE id = $1", userID)
require.NoError(t, err)
// Create 2 no-show records
for i := 0; i < 2; i++ {
bid := createPendingBooking(t, userID, serviceID, time.Now().Add(-time.Duration(i+1)*time.Hour))
insertPaymentForBooking(t, bid, userID, 5000)
_, err := db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", bid)
require.NoError(t, err)
}
// Complete 3 paid bookings — each should decrement deposits_required
for i := 0; i < 3; i++ {
bid := createPendingBooking(t, userID, serviceID, time.Now().Add(time.Duration(i+1)*time.Hour))
insertPaymentForBooking(t, bid, userID, 5000)
completeBooking(t, bid)
}
// After 3 completions, deposits_required should be 0
var depositsRequired int
db.DB.QueryRow(context.Background(),
"SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&depositsRequired)
assert.Equal(t, 0, depositsRequired, "Expected 0 after 3 paid bookings from 3")
// No-show records should be forgiven (inserted into forgiven_no_shows)
var forgivenCount int
db.DB.QueryRow(context.Background(), `
SELECT COUNT(*) FROM forgiven_no_shows fns
JOIN bookings b ON b.id = fns.booking_id
WHERE b.user_id = $1
`, userID).Scan(&forgivenCount)
// Try again — should NOT trigger deposits_required again since no-shows are forgiven
applied, _ := ApplyDepositsIfNeeded(context.Background(), userID)
assert.False(t, applied, "Should not trigger: no-shows were forgiven after 3 paid bookings")
}
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -1,5 +1,5 @@
//go:build test
// +build test
//go:build test && dev
// +build test,dev
package bookings
@@ -8,8 +8,10 @@ import (
"testing"
"crussell/db"
"crussell/testutils/testdb"
"crussell/handlers/payments"
"crussell/internal/square"
"crussell/testutils/jwt"
"crussell/testutils/testdb"
)
func TestMain(m *testing.M) {
@@ -20,6 +22,8 @@ func TestMain(m *testing.M) {
testdb.Migrate(&testing.T{}, pool)
db.DB = pool
jwt.Init()
square.Client = square.NewDevClient()
payments.SquareClient = square.Client
code := m.Run()
pool.Close()
os.Exit(code)