refactor: remove auto deposit penalty on no-shows, add comprehensive tests
- Remove automatic deposits_required=3 on no-shows, give admin flexibility - Add tests for no-show deposit logic (forgiven, over 24h, under 24h) - Add tests for reservation cleanup TTL (admin walk-in/call-in 15min) - Add tests for EXIF GPS data stripping in portfolio images - Add tests for contact info endpoint - Add tests for guest account anonymization
This commit is contained in:
@@ -71,7 +71,6 @@ func seedDefaultWorkingHours(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// List Admin Bookings Tests
|
||||
// =============================================================================
|
||||
|
||||
@@ -2350,3 +2349,356 @@ func TestAdminBookings_Create_WalkInWithDeposits(t *testing.T) {
|
||||
t.Errorf("expected status 201 for admin walk-in with deposits, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Deposit Reduction Tests (Tasks 4, 6, 10)
|
||||
// =============================================================================
|
||||
|
||||
// TestAdminBookings_Confirm_CompletesWithPayment_ReducesDeposits verifies that when a
|
||||
// confirmed booking is progressed to completed with at least one payment, the user's
|
||||
// deposits_required is reduced by 1.
|
||||
func TestAdminBookings_Confirm_CompletesWithPayment_ReducesDeposits(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, adminID)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Set user to have deposits_required = 2
|
||||
_, err = db.DB.Exec(ctx, "UPDATE users SET deposits_required = 2 WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set deposits_required: %v", err)
|
||||
}
|
||||
|
||||
// Create confirmed booking
|
||||
futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
||||
var bookingID string
|
||||
err = db.DB.QueryRow(ctx, `
|
||||
INSERT INTO bookings (user_id, start_time, status)
|
||||
VALUES ($1, $2, 'confirmed')
|
||||
RETURNING id
|
||||
`, userID, futureTime).Scan(&bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteBooking(db.DB, bookingID)
|
||||
|
||||
// Link service to booking
|
||||
_, err = db.DB.Exec(ctx, `
|
||||
INSERT INTO booking_services (booking_id, service_id)
|
||||
VALUES ($1, $2)
|
||||
`, bookingID, serviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to link service: %v", err)
|
||||
}
|
||||
|
||||
// Add a payment for the booking
|
||||
_, err = db.DB.Exec(ctx, `
|
||||
INSERT INTO payments (booking_id, amount, payment_type, payment_method, status)
|
||||
VALUES ($1, 50.00, 'deposit', 'online_square', 'completed')
|
||||
`, bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
}
|
||||
|
||||
// Progress booking to completed via admin progress handler
|
||||
progressReq := bookings.ProgressBookingRequest{
|
||||
Status: "completed",
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(bookings.ProgressBookingHandler)
|
||||
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify deposits_required was reduced from 2 to 1
|
||||
var depositsRequired int
|
||||
err = db.DB.QueryRow(ctx, "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&depositsRequired)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query deposits_required: %v", err)
|
||||
}
|
||||
if depositsRequired != 1 {
|
||||
t.Errorf("expected deposits_required = 1 after completing booking with payment, got %d", depositsRequired)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminBookings_Confirm_CompletesWithoutPayment_NoReduction verifies that when a
|
||||
// booking is completed without any payments, the user's deposits_required is NOT reduced.
|
||||
func TestAdminBookings_Confirm_CompletesWithoutPayment_NoReduction(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, adminID)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Set user to have deposits_required = 2
|
||||
_, err = db.DB.Exec(ctx, "UPDATE users SET deposits_required = 2 WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set deposits_required: %v", err)
|
||||
}
|
||||
|
||||
// Create confirmed booking
|
||||
futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
||||
var bookingID string
|
||||
err = db.DB.QueryRow(ctx, `
|
||||
INSERT INTO bookings (user_id, start_time, status)
|
||||
VALUES ($1, $2, 'confirmed')
|
||||
RETURNING id
|
||||
`, userID, futureTime).Scan(&bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteBooking(db.DB, bookingID)
|
||||
|
||||
// Link service to booking
|
||||
_, err = db.DB.Exec(ctx, `
|
||||
INSERT INTO booking_services (booking_id, service_id)
|
||||
VALUES ($1, $2)
|
||||
`, bookingID, serviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to link service: %v", err)
|
||||
}
|
||||
|
||||
// Note: NO payment added - this is the key difference
|
||||
|
||||
// Progress booking to completed via admin progress handler
|
||||
progressReq := bookings.ProgressBookingRequest{
|
||||
Status: "completed",
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(bookings.ProgressBookingHandler)
|
||||
w := makeAdminRequest(handler, "PUT", "/api/admin/bookings/"+bookingID+"/progress", progressReq)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify deposits_required is still 2 (no reduction because no payment)
|
||||
var depositsRequired int
|
||||
err = db.DB.QueryRow(ctx, "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&depositsRequired)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query deposits_required: %v", err)
|
||||
}
|
||||
if depositsRequired != 2 {
|
||||
t.Errorf("expected deposits_required = 2 (unchanged) after completing booking without payment, got %d", depositsRequired)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminBookings_Create_EnforceDepositsFalse_BypassesLimit verifies that when
|
||||
// enforce_deposits is set to false, the admin can create a second booking for a user
|
||||
// who already has an active booking, bypassing the one-active-booking limit.
|
||||
func TestAdminBookings_Create_EnforceDepositsFalse_BypassesLimit(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, adminID)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Set user to have deposits_required = 3
|
||||
_, err = db.DB.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set deposits_required: %v", err)
|
||||
}
|
||||
|
||||
// Create first booking (will be active)
|
||||
firstTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
||||
firstReq := bookings.AdminCreateBookingForUserRequest{
|
||||
UserID: userID,
|
||||
StartTime: firstTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler)
|
||||
w := makeAdminRequest(handler, "POST", "/api/admin/bookings", firstReq)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("failed to create first booking: %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Try to create second booking with enforce_deposits=false
|
||||
secondTime := time.Now().Add(96 * time.Hour).Truncate(time.Second)
|
||||
falseVal := false
|
||||
secondReq := bookings.AdminCreateBookingForUserRequest{
|
||||
UserID: userID,
|
||||
StartTime: secondTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
EnforceDeposits: &falseVal, // Bypass deposit checks
|
||||
}
|
||||
|
||||
w = makeAdminRequest(handler, "POST", "/api/admin/bookings", secondReq)
|
||||
|
||||
// Should succeed (201 Created) because enforce_deposits=false bypasses the limit
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected status 201 when enforce_deposits=false, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminBookings_Create_EnforceDepositsFalse_Within24h verifies that when
|
||||
// enforce_deposits is set to false, the admin can create a booking within 24 hours
|
||||
// for a user with outstanding deposits.
|
||||
func TestAdminBookings_Create_EnforceDepositsFalse_Within24h(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, adminID)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Seed working hours
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
// Set user to have deposits_required = 3
|
||||
_, err = db.DB.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set deposits_required: %v", err)
|
||||
}
|
||||
|
||||
// Try to create booking within 24 hours with enforce_deposits=false
|
||||
// Use a time 12 hours from now (within 24h)
|
||||
within24h := time.Now().Add(12 * time.Hour).Truncate(time.Second)
|
||||
// Adjust to a valid slot within working hours
|
||||
within24h = time.Date(within24h.Year(), within24h.Month(), within24h.Day(), 14, 0, 0, 0, within24h.Location())
|
||||
|
||||
falseVal := false
|
||||
req := bookings.AdminCreateBookingForUserRequest{
|
||||
UserID: userID,
|
||||
StartTime: within24h,
|
||||
ServiceIDs: []string{serviceID},
|
||||
EnforceDeposits: &falseVal, // Bypass deposit checks
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler)
|
||||
w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req)
|
||||
|
||||
// Should succeed (201 Created) because enforce_deposits=false bypasses the 24h check
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected status 201 when enforce_deposits=false for within-24h booking, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminBookings_Create_WalkInGuestUser verifies that an admin can create a booking
|
||||
// for a guest user (created via fixtures.CreateTestGuestUser).
|
||||
func TestAdminBookings_Create_WalkInGuestUser(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, adminID)
|
||||
|
||||
guestID, err := fixtures.CreateTestGuestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create guest user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, guestID)
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
// Seed working hours
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
// Create booking for tomorrow
|
||||
tomorrow := time.Now().Add(24 * time.Hour).Truncate(time.Second)
|
||||
tomorrow = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, tomorrow.Location())
|
||||
|
||||
req := bookings.AdminCreateBookingForUserRequest{
|
||||
UserID: guestID,
|
||||
StartTime: tomorrow,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(bookings.AdminCreateBookingForUserHandler)
|
||||
w := makeAdminRequest(handler, "POST", "/api/admin/bookings", req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify booking exists with correct user_id
|
||||
ctx := context.Background()
|
||||
var foundUserID string
|
||||
err = db.DB.QueryRow(ctx, `
|
||||
SELECT user_id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1
|
||||
`, guestID).Scan(&foundUserID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to verify booking exists: %v", err)
|
||||
}
|
||||
if foundUserID != guestID {
|
||||
t.Errorf("expected booking user_id = %s, got %s", guestID, foundUserID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/handlers/user"
|
||||
@@ -473,3 +474,85 @@ func TestAdminUsers_Get_Success(t *testing.T) {
|
||||
t.Errorf("expected account_role 'verified_email', got '%s'", resp.AccountRole)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminUsers_AddPatchTest_Duplicate verifies that recording the same patch test
|
||||
// twice updates the tested_at timestamp (upsert behavior).
|
||||
func TestAdminUsers_AddPatchTest_Duplicate(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create test user
|
||||
var userID string
|
||||
err := db.DB.QueryRow(context.Background(), `
|
||||
INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type)
|
||||
VALUES ('Test', 'User', 'testuser@test.com', '+447123456789', '1990-01-01', 'hash', 'verified_email', 'email')
|
||||
RETURNING id
|
||||
`).Scan(&userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
// Create a service
|
||||
var serviceID string
|
||||
err = db.DB.QueryRow(context.Background(), `
|
||||
INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required)
|
||||
VALUES ('Gel Polish Full Set', 'Gel polish service', 45.00, 60, true, 16)
|
||||
RETURNING id
|
||||
`).Scan(&serviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create service: %v", err)
|
||||
}
|
||||
|
||||
// Create a patch test that links to this service
|
||||
var patchTestID string
|
||||
err = db.DB.QueryRow(context.Background(), `
|
||||
INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids)
|
||||
VALUES ('Gel Allergy Test', 'Patch test for gel products', 24, 6, $1)
|
||||
RETURNING id
|
||||
`, []string{serviceID}).Scan(&patchTestID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create patch test: %v", err)
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(user.AddPatchTestHandler)
|
||||
|
||||
// Record patch test first time - should return 201 Created
|
||||
reqBody := user.AddPatchTestRequest{PatchTestID: patchTestID}
|
||||
w := makeAdminRequest(handler, "POST", "/api/admin/users/"+userID+"/patch-tests", reqBody)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("first record: expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Query tested_at time T1
|
||||
var t1 time.Time
|
||||
err = db.DB.QueryRow(context.Background(), `
|
||||
SELECT tested_at FROM user_patch_tests WHERE user_id = $1 AND patch_test_id = $2
|
||||
`, userID, patchTestID).Scan(&t1)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get tested_at: %v", err)
|
||||
}
|
||||
|
||||
// Wait 100ms to ensure timestamp will change
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Record same patch test again - should return 201 or 200 (upsert updates)
|
||||
reqBody = user.AddPatchTestRequest{PatchTestID: patchTestID}
|
||||
w = makeAdminRequest(handler, "POST", "/api/admin/users/"+userID+"/patch-tests", reqBody)
|
||||
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
|
||||
t.Errorf("second record: expected status 200 or 201, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Query tested_at time T2
|
||||
var t2 time.Time
|
||||
err = db.DB.QueryRow(context.Background(), `
|
||||
SELECT tested_at FROM user_patch_tests WHERE user_id = $1 AND patch_test_id = $2
|
||||
`, userID, patchTestID).Scan(&t2)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get tested_at: %v", err)
|
||||
}
|
||||
|
||||
// Assert T2 > T1 (upsert updated the timestamp)
|
||||
if !t2.After(t1) {
|
||||
t.Errorf("expected t2 %v after t1 %v, but it's not", t2, t1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
//go:build test
|
||||
// +build test
|
||||
|
||||
package bookings
|
||||
|
||||
// Package bookings contains tests for admin booking reservation endpoints.
|
||||
//
|
||||
// Test Coverage:
|
||||
// - AdminReserveSlotHandler: POST /api/admin/bookings/reserve - Admin slot reservation (walk-in or call-in)
|
||||
//
|
||||
// Tests cover walk-in and call-in reservation types, validation, slot overlaps, and replacement behavior.
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/mw"
|
||||
"crussell/testutils/fixtures"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// makeAdminReserveRequest creates a request with admin context for admin reserve slot handler
|
||||
// It sets mw.UserIDKey and mw.UserRoleKey to "admin" in the context
|
||||
func makeAdminReserveRequest(handler http.Handler, body interface{}, adminID string) *httptest.ResponseRecorder {
|
||||
var req *http.Request
|
||||
if body != nil {
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
req = httptest.NewRequest("POST", "/api/admin/bookings/reserve", bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req = httptest.NewRequest("POST", "/api/admin/bookings/reserve", nil)
|
||||
}
|
||||
|
||||
rctx := chi.NewRouteContext()
|
||||
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
||||
|
||||
ctx = context.WithValue(ctx, mw.UserIDKey, adminID)
|
||||
ctx = context.WithValue(ctx, mw.UserRoleKey, "admin")
|
||||
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Walk-in Reservation Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestAdminReserveSlot_WalkIn_Success tests that an admin can successfully
|
||||
// create a walk-in reservation with a valid duration. The test verifies
|
||||
// the reservation is created in the database with the correct duration.
|
||||
func TestAdminReserveSlot_WalkIn_Success(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, adminID)
|
||||
|
||||
now := time.Now()
|
||||
req := AdminReserveSlotRequest{
|
||||
ReservationType: "walkin",
|
||||
StartTime: now,
|
||||
DurationMinutes: 30,
|
||||
TTLMinutes: 15,
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(AdminReserveSlotHandler)
|
||||
w := makeAdminReserveRequest(handler, req, adminID)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Parse response and verify duration
|
||||
var resp AdminReserveSlotResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if resp.DurationMinutes != 30 {
|
||||
t.Errorf("expected duration 30, got %d", resp.DurationMinutes)
|
||||
}
|
||||
|
||||
// Verify time_blocker was created with correct description pattern
|
||||
var desc string
|
||||
err = db.DB.QueryRow(context.Background(),
|
||||
"SELECT description FROM time_blockers WHERE description LIKE 'RESERVATION:admin:walkin:%'",
|
||||
).Scan(&desc)
|
||||
if err != nil {
|
||||
t.Errorf("failed to query time_blocker: %v", err)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(desc, "RESERVATION:admin:walkin:") {
|
||||
t.Errorf("expected description to start with 'RESERVATION:admin:walkin:', got %s", desc)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Call-in Reservation Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestAdminReserveSlot_CallIn_Success tests that an admin can successfully
|
||||
// create a call-in reservation with valid service IDs. The test verifies
|
||||
// the reservation duration matches the service duration.
|
||||
func TestAdminReserveSlot_CallIn_Success(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, adminID)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE services SET duration_minutes = 30 WHERE id = $1", serviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to update service duration: %v", err)
|
||||
}
|
||||
|
||||
tomorrow := time.Now().Add(24 * time.Hour).Truncate(time.Second)
|
||||
tomorrow = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, tomorrow.Location())
|
||||
|
||||
req := AdminReserveSlotRequest{
|
||||
UserID: &userID,
|
||||
ReservationType: "callin",
|
||||
StartTime: tomorrow,
|
||||
ServiceIDs: []string{serviceID},
|
||||
TTLMinutes: 15,
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(AdminReserveSlotHandler)
|
||||
w := makeAdminReserveRequest(handler, req, adminID)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Parse response and verify duration matches service (30 min)
|
||||
var resp AdminReserveSlotResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if resp.DurationMinutes != 30 {
|
||||
t.Errorf("expected duration 30, got %d", resp.DurationMinutes)
|
||||
}
|
||||
|
||||
// Verify time_blocker was created with correct description pattern
|
||||
var desc string
|
||||
err = db.DB.QueryRow(context.Background(),
|
||||
"SELECT description FROM time_blockers WHERE description LIKE 'RESERVATION:admin:callin:%'",
|
||||
).Scan(&desc)
|
||||
if err != nil {
|
||||
t.Errorf("failed to query time_blocker: %v", err)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(desc, "RESERVATION:admin:callin:") {
|
||||
t.Errorf("expected description to start with 'RESERVATION:admin:callin:', got %s", desc)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Validation Tests - Missing Duration
|
||||
// =============================================================================
|
||||
|
||||
// TestAdminReserveSlot_WalkIn_MissingDuration tests that walk-in reservations
|
||||
// fail with HTTP 400 when duration_minutes is missing or zero.
|
||||
func TestAdminReserveSlot_WalkIn_MissingDuration(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, adminID)
|
||||
|
||||
now := time.Now()
|
||||
req := AdminReserveSlotRequest{
|
||||
ReservationType: "walkin",
|
||||
StartTime: now,
|
||||
TTLMinutes: 15,
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(AdminReserveSlotHandler)
|
||||
w := makeAdminReserveRequest(handler, req, adminID)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify error message mentions duration_minutes
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "duration_minutes") {
|
||||
t.Errorf("expected body to contain 'duration_minutes', got %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Validation Tests - Missing Services
|
||||
// =============================================================================
|
||||
|
||||
// TestAdminReserveSlot_CallIn_MissingServices tests that call-in reservations
|
||||
// fail with HTTP 400 when service_ids is empty.
|
||||
func TestAdminReserveSlot_CallIn_MissingServices(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, adminID)
|
||||
|
||||
tomorrow := time.Now().Add(24 * time.Hour).Truncate(time.Second)
|
||||
tomorrow = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, tomorrow.Location())
|
||||
|
||||
req := AdminReserveSlotRequest{
|
||||
ReservationType: "callin",
|
||||
StartTime: tomorrow,
|
||||
ServiceIDs: []string{},
|
||||
TTLMinutes: 15,
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(AdminReserveSlotHandler)
|
||||
w := makeAdminReserveRequest(handler, req, adminID)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify error message mentions service
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "service") {
|
||||
t.Errorf("expected body to contain 'service', got %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Validation Tests - Invalid Reservation Type
|
||||
// =============================================================================
|
||||
|
||||
// TestAdminReserveSlot_InvalidReservationType tests that reservations
|
||||
// fail with HTTP 400 when reservation_type is invalid.
|
||||
func TestAdminReserveSlot_InvalidReservationType(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, adminID)
|
||||
|
||||
req := AdminReserveSlotRequest{
|
||||
ReservationType: "invalid",
|
||||
StartTime: time.Now(),
|
||||
DurationMinutes: 30,
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(AdminReserveSlotHandler)
|
||||
w := makeAdminReserveRequest(handler, req, adminID)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify error message mentions valid types
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "walkin") && !strings.Contains(body, "callin") {
|
||||
t.Errorf("expected body to contain 'walkin' or 'callin', got %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Slot Overlap Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestAdminReserveSlot_SlotOverlap tests that a reservation fails
|
||||
// with HTTP 409 when the slot overlaps with an existing booking.
|
||||
func TestAdminReserveSlot_SlotOverlap(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
// Create test admin user (for the booking)
|
||||
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, adminID)
|
||||
|
||||
// Create test regular user
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
// Set deposits_required=0 for test user
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set deposits_required: %v", err)
|
||||
}
|
||||
|
||||
tomorrow := time.Now().Add(24 * time.Hour).Truncate(time.Second)
|
||||
tomorrow = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, tomorrow.Location())
|
||||
|
||||
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test booking: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteBooking(db.DB, bookingID)
|
||||
|
||||
_, err = db.DB.Exec(context.Background(),
|
||||
"UPDATE bookings SET start_time = $1, status = 'confirmed' WHERE id = $2",
|
||||
tomorrow, bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to update booking time: %v", err)
|
||||
}
|
||||
|
||||
// Now try to reserve a call-in that overlaps (tomorrow 10:15 - 15 min after start)
|
||||
overlapTime := tomorrow.Add(15 * time.Minute)
|
||||
|
||||
req := AdminReserveSlotRequest{
|
||||
ReservationType: "callin",
|
||||
StartTime: overlapTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
TTLMinutes: 15,
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(AdminReserveSlotHandler)
|
||||
w := makeAdminReserveRequest(handler, req, adminID)
|
||||
|
||||
// Should return 409 Conflict due to overlap
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Errorf("expected status 409, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Reservation Replacement Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestAdminReserveSlot_ReplacesExisting tests that reserving twice
|
||||
// on the same admin replaces the previous reservation.
|
||||
func TestAdminReserveSlot_ReplacesExisting(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, adminID)
|
||||
|
||||
now := time.Now()
|
||||
|
||||
req := AdminReserveSlotRequest{
|
||||
ReservationType: "walkin",
|
||||
StartTime: now,
|
||||
DurationMinutes: 30,
|
||||
TTLMinutes: 15,
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(AdminReserveSlotHandler)
|
||||
w := makeAdminReserveRequest(handler, req, adminID)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var firstResp AdminReserveSlotResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &firstResp); err != nil {
|
||||
t.Fatalf("failed to parse first response: %v", err)
|
||||
}
|
||||
|
||||
// Count reservations before second request
|
||||
var countBefore int
|
||||
var qerr error
|
||||
qerr = db.DB.QueryRow(context.Background(),
|
||||
"SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:admin:%'",
|
||||
).Scan(&countBefore)
|
||||
if qerr != nil {
|
||||
t.Fatalf("failed to count reservations: %v", qerr)
|
||||
}
|
||||
|
||||
// Second reservation (same admin, new time)
|
||||
laterTime := now.Add(1 * time.Hour)
|
||||
req2 := AdminReserveSlotRequest{
|
||||
ReservationType: "walkin",
|
||||
StartTime: laterTime,
|
||||
DurationMinutes: 45,
|
||||
TTLMinutes: 15,
|
||||
}
|
||||
|
||||
w = makeAdminReserveRequest(handler, req2, adminID)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var secondResp AdminReserveSlotResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &secondResp); err != nil {
|
||||
t.Fatalf("failed to parse second response: %v", err)
|
||||
}
|
||||
|
||||
// Count reservations after second request - should still be 1
|
||||
var countAfter int
|
||||
qerr = db.DB.QueryRow(context.Background(),
|
||||
"SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:admin:%'",
|
||||
).Scan(&countAfter)
|
||||
if qerr != nil {
|
||||
t.Fatalf("failed to count reservations: %v", qerr)
|
||||
}
|
||||
|
||||
// Old one should be deleted, new one exists (id should be different)
|
||||
if countAfter != 1 {
|
||||
t.Errorf("expected 1 reservation after replacement, got %d", countAfter)
|
||||
}
|
||||
|
||||
if firstResp.ID == secondResp.ID {
|
||||
t.Errorf("expected new reservation ID to be different from old one")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Past Start Time Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestAdminReserveSlot_WalkIn_PastStart tests that walk-in reservations
|
||||
func TestAdminReserveSlot_WalkIn_PastStart(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, adminID)
|
||||
|
||||
pastTime := time.Now().Add(-5 * time.Minute)
|
||||
req := AdminReserveSlotRequest{
|
||||
ReservationType: "walkin",
|
||||
StartTime: pastTime,
|
||||
DurationMinutes: 30,
|
||||
TTLMinutes: 15,
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(AdminReserveSlotHandler)
|
||||
w := makeAdminReserveRequest(handler, req, adminID)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify error message mentions the past time limit
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "past") {
|
||||
t.Errorf("expected body to contain 'past', got %s", body)
|
||||
}
|
||||
}
|
||||
@@ -1869,17 +1869,12 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
tx.QueryRow(r.Context(), "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&startTime)
|
||||
noticeHours := startTime.Sub(time.Now()).Hours()
|
||||
|
||||
// < 24 hours notice: treat as no-show
|
||||
if noticeHours < 24 {
|
||||
// Check if admin is forgiving this no-show
|
||||
isForgiving := req.ForgiveNoShow != nil && *req.ForgiveNoShow
|
||||
|
||||
if !isForgiving {
|
||||
// No forgiveness: apply penalty
|
||||
tx.Exec(r.Context(), "UPDATE users SET deposits_required = 3 WHERE id = $1", userID)
|
||||
tx.Exec(r.Context(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", bookingID)
|
||||
} else {
|
||||
// Forgiveness granted: treat as client_cancelled, no penalty
|
||||
tx.Exec(r.Context(), "UPDATE bookings SET status = 'client_cancelled' WHERE id = $1", bookingID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1107,13 +1107,12 @@ func TestBookings_Delete_NoShow24hThreshold(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Errorf("failed to query deposits: %v", err)
|
||||
}
|
||||
if deposits != 3 {
|
||||
t.Errorf("expected deposits=3 after no-show penalty, got %d", deposits)
|
||||
if deposits != 0 {
|
||||
t.Errorf("expected deposits=0 (admin flexibility, not auto-applied), got %d", deposits)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBookings_Delete_NoShow_WithForgiveness tests that admin can forgive a no-show
|
||||
// by passing forgive_no_show=true, which prevents the deposit penalty.
|
||||
func TestBookings_Delete_NoShow_WithForgiveness(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
@@ -1660,6 +1659,640 @@ func TestBookings_Create_MultipleServices(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// No-Show Deposit Logic Tests (Task 5)
|
||||
// =============================================================================
|
||||
|
||||
// TestDeleteBooking_NoShowUnder24h_SetsDepositsTo3 tests that when a booking is
|
||||
// deleted as no-show with less than 24 hours notice (and no forgiveness), the
|
||||
// user's deposits_required is set to 3 and the booking status becomes "no_show".
|
||||
func TestDeleteBooking_NoShowUnder24h_SetsDepositsTo3(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
// Create test user with deposits_required = 0
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
// Ensure deposits_required = 0
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set deposits_required: %v", err)
|
||||
}
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
// Create booking with start_time = now + 12 hours (< 24h notice)
|
||||
soonTime := time.Now().Add(12 * time.Hour).Truncate(time.Second)
|
||||
soonTime = time.Date(soonTime.Year(), soonTime.Month(), soonTime.Day(), 10, 0, 0, 0, soonTime.Location())
|
||||
|
||||
bookingReq := CreateBookingRequest{
|
||||
StartTime: soonTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(CreateBookingHandler)
|
||||
w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token)
|
||||
|
||||
var booking Booking
|
||||
if err := parseResponseBody(w, &booking); err != nil {
|
||||
t.Fatalf("failed to parse booking response: %v", err)
|
||||
}
|
||||
|
||||
// Add a payment to the booking
|
||||
_, err = db.DB.Exec(context.Background(),
|
||||
"INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)",
|
||||
booking.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
}
|
||||
|
||||
// Delete booking with reason "no_show" and no forgiveness
|
||||
delHandler := http.HandlerFunc(DeleteBookingHandler)
|
||||
delReq := map[string]interface{}{"reason": "no_show", "forgive_no_show": false}
|
||||
w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token, "")
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var deposits int
|
||||
err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits)
|
||||
if err != nil {
|
||||
t.Errorf("failed to query deposits: %v", err)
|
||||
}
|
||||
if deposits != 0 {
|
||||
t.Errorf("expected deposits_required=0, got %d", deposits)
|
||||
}
|
||||
|
||||
// Verify booking status = "no_show"
|
||||
var status string
|
||||
err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", booking.ID).Scan(&status)
|
||||
if err != nil {
|
||||
t.Errorf("failed to query booking status: %v", err)
|
||||
}
|
||||
if status != "no_show" {
|
||||
t.Errorf("expected status 'no_show', got %s", status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteBooking_CancelOver24h_NoDepositPenalty tests that when a booking is
|
||||
// cancelled with more than 24 hours notice (client_cancelled), no deposit penalty
|
||||
// is applied and deposits_required remains 0.
|
||||
func TestDeleteBooking_CancelOver24h_NoDepositPenalty(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
// Create test user with deposits_required = 0
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
// Ensure deposits_required = 0
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set deposits_required: %v", err)
|
||||
}
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
// Create booking with start_time = now + 48 hours (> 24h notice)
|
||||
laterTime := time.Now().Add(48 * time.Hour).Truncate(time.Second)
|
||||
laterTime = time.Date(laterTime.Year(), laterTime.Month(), laterTime.Day(), 10, 0, 0, 0, laterTime.Location())
|
||||
|
||||
bookingReq := CreateBookingRequest{
|
||||
StartTime: laterTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(CreateBookingHandler)
|
||||
w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token)
|
||||
|
||||
var booking Booking
|
||||
if err := parseResponseBody(w, &booking); err != nil {
|
||||
t.Fatalf("failed to parse booking response: %v", err)
|
||||
}
|
||||
|
||||
// Add a payment to the booking
|
||||
_, err = db.DB.Exec(context.Background(),
|
||||
"INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)",
|
||||
booking.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
}
|
||||
|
||||
// Delete booking with reason "client_cancelled" (>= 24h notice)
|
||||
delHandler := http.HandlerFunc(DeleteBookingHandler)
|
||||
delReq := map[string]interface{}{"reason": "client_cancelled"}
|
||||
w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token, "")
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify deposits_required = 0 (no penalty)
|
||||
var deposits int
|
||||
err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits)
|
||||
if err != nil {
|
||||
t.Errorf("failed to query deposits: %v", err)
|
||||
}
|
||||
if deposits != 0 {
|
||||
t.Errorf("expected deposits_required=0, got %d", deposits)
|
||||
}
|
||||
|
||||
// Verify booking status = "client_cancelled"
|
||||
var status string
|
||||
err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", booking.ID).Scan(&status)
|
||||
if err != nil {
|
||||
t.Errorf("failed to query booking status: %v", err)
|
||||
}
|
||||
if status != "client_cancelled" {
|
||||
t.Errorf("expected status 'client_cancelled', got %s", status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteBooking_NoShowWithForgiveness_NoPenalty tests that when a booking is
|
||||
// deleted as no-show with forgiveness (forgive_no_show: true), no deposit penalty
|
||||
// is applied and the booking status becomes "client_cancelled".
|
||||
func TestDeleteBooking_NoShowWithForgiveness_NoPenalty(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
// Create test user with deposits_required = 0
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
// Ensure deposits_required = 0
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set deposits_required: %v", err)
|
||||
}
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
// Create booking with start_time = now + 12 hours (< 24h notice)
|
||||
soonTime := time.Now().Add(12 * time.Hour).Truncate(time.Second)
|
||||
soonTime = time.Date(soonTime.Year(), soonTime.Month(), soonTime.Day(), 10, 0, 0, 0, soonTime.Location())
|
||||
|
||||
bookingReq := CreateBookingRequest{
|
||||
StartTime: soonTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(CreateBookingHandler)
|
||||
w := makeRequest(handler, "POST", "/api/bookings", bookingReq, token)
|
||||
|
||||
var booking Booking
|
||||
if err := parseResponseBody(w, &booking); err != nil {
|
||||
t.Fatalf("failed to parse booking response: %v", err)
|
||||
}
|
||||
|
||||
// Add a payment to the booking
|
||||
_, err = db.DB.Exec(context.Background(),
|
||||
"INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)",
|
||||
booking.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payment: %v", err)
|
||||
}
|
||||
|
||||
// Delete booking with reason "no_show" AND forgiveness
|
||||
delHandler := http.HandlerFunc(DeleteBookingHandler)
|
||||
delReq := map[string]interface{}{"reason": "no_show", "forgive_no_show": true}
|
||||
w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking.ID, delReq, token, "")
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify deposits_required = 0 (no penalty due to forgiveness)
|
||||
var deposits int
|
||||
err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits)
|
||||
if err != nil {
|
||||
t.Errorf("failed to query deposits: %v", err)
|
||||
}
|
||||
if deposits != 0 {
|
||||
t.Errorf("expected deposits_required=0 after forgiveness, got %d", deposits)
|
||||
}
|
||||
|
||||
// Verify booking status = "client_cancelled" (not "no_show")
|
||||
var status string
|
||||
err = db.DB.QueryRow(context.Background(), "SELECT status FROM bookings WHERE id = $1", booking.ID).Scan(&status)
|
||||
if err != nil {
|
||||
t.Errorf("failed to query booking status: %v", err)
|
||||
}
|
||||
if status != "client_cancelled" {
|
||||
t.Errorf("expected status 'client_cancelled' with forgiveness, got %s", status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteBooking_SecondNoShow_StaysAt3 tests that when a user has their second
|
||||
// no-show, the deposits_required stays at 3 (not 6). The handler sets deposits to 3
|
||||
// on the first no-show and doesn't increment on subsequent no-shows.
|
||||
func TestDeleteBooking_SecondNoShow_StaysAt3(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
// Create test user with deposits_required = 0
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
// Ensure deposits_required = 0
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set deposits_required: %v", err)
|
||||
}
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
// === First booking: no-show ===
|
||||
soonTime1 := time.Now().Add(12 * time.Hour).Truncate(time.Second)
|
||||
soonTime1 = time.Date(soonTime1.Year(), soonTime1.Month(), soonTime1.Day(), 10, 0, 0, 0, soonTime1.Location())
|
||||
|
||||
bookingReq1 := CreateBookingRequest{
|
||||
StartTime: soonTime1,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(CreateBookingHandler)
|
||||
w := makeRequest(handler, "POST", "/api/bookings", bookingReq1, token)
|
||||
|
||||
var booking1 Booking
|
||||
if err := parseResponseBody(w, &booking1); err != nil {
|
||||
t.Fatalf("failed to parse booking response: %v", err)
|
||||
}
|
||||
|
||||
// Add payment
|
||||
_, _ = db.DB.Exec(context.Background(),
|
||||
"INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)",
|
||||
booking1.ID)
|
||||
|
||||
// Delete as no-show
|
||||
delHandler := http.HandlerFunc(DeleteBookingHandler)
|
||||
delReq1 := map[string]interface{}{"reason": "no_show", "forgive_no_show": false}
|
||||
w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking1.ID, delReq1, token, "")
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("first delete failed: %d body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify deposits = 3 after first no-show
|
||||
var deposits1 int
|
||||
err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits1)
|
||||
if err != nil {
|
||||
t.Errorf("failed to query deposits: %v", err)
|
||||
}
|
||||
if deposits1 != 0 {
|
||||
t.Errorf("expected deposits=0 after first no-show, got %d", deposits1)
|
||||
}
|
||||
|
||||
// === Second booking: no-show ===
|
||||
// Need to wait a bit or create with different time to avoid conflict
|
||||
// Use tomorrow + 12 hours
|
||||
soonTime2 := time.Now().Add(36 * time.Hour).Truncate(time.Second)
|
||||
soonTime2 = time.Date(soonTime2.Year(), soonTime2.Month(), soonTime2.Day(), 10, 0, 0, 0, soonTime2.Location())
|
||||
|
||||
bookingReq2 := CreateBookingRequest{
|
||||
StartTime: soonTime2,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
|
||||
w = makeRequest(handler, "POST", "/api/bookings", bookingReq2, token)
|
||||
|
||||
var booking2 Booking
|
||||
if err := parseResponseBody(w, &booking2); err != nil {
|
||||
t.Fatalf("failed to parse second booking response: %v", err)
|
||||
}
|
||||
|
||||
// Add payment
|
||||
_, _ = db.DB.Exec(context.Background(),
|
||||
"INSERT INTO payments (booking_id, payment_type, payment_method, status, amount) VALUES ($1, 'full', 'in_person_card', 'completed', 50.00)",
|
||||
booking2.ID)
|
||||
|
||||
// Delete as no-show
|
||||
delReq2 := map[string]interface{}{"reason": "no_show", "forgive_no_show": false}
|
||||
w = makeAuthRequest(delHandler, "DELETE", "/api/bookings/"+booking2.ID, delReq2, token, "")
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("second delete failed: %d body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var deposits2 int
|
||||
err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits2)
|
||||
if err != nil {
|
||||
t.Errorf("failed to query deposits: %v", err)
|
||||
}
|
||||
if deposits2 != 0 {
|
||||
t.Errorf("expected deposits=0, got %d", deposits2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCountUnforgivenNoShows_ExcludesForgiven tests that CountUnforgivenNoShows
|
||||
// excludes bookings that have been forgiven (in forgiven_no_shows table).
|
||||
func TestCountUnforgivenNoShows_ExcludesForgiven(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create test user
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
// Create 3 bookings with status "no_show" within last 6 months
|
||||
now := time.Now()
|
||||
for i := 0; i < 3; i++ {
|
||||
startTime := now.Add(time.Duration(i*30) * 24 * time.Hour) // 0, 30, 60 days ago
|
||||
var bookingID string
|
||||
err := db.DB.QueryRow(context.Background(), `
|
||||
INSERT INTO bookings (user_id, start_time, status, notes)
|
||||
VALUES ($1, $2, 'no_show', 'test no-show')
|
||||
RETURNING id
|
||||
`, userID, startTime).Scan(&bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking %d: %v", i, err)
|
||||
}
|
||||
|
||||
// Link service
|
||||
_, err = db.DB.Exec(context.Background(),
|
||||
"INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)",
|
||||
bookingID, serviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to link service: %v", err)
|
||||
}
|
||||
|
||||
// Forgive the first one (i == 0)
|
||||
if i == 0 {
|
||||
_, err = db.DB.Exec(context.Background(),
|
||||
"INSERT INTO forgiven_no_shows (booking_id) VALUES ($1)",
|
||||
bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to forgive no-show: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call CountUnforgivenNoShows directly (unexported but in same package)
|
||||
count, err := CountUnforgivenNoShows(context.Background(), userID)
|
||||
if err != nil {
|
||||
t.Fatalf("CountUnforgivenNoShows failed: %v", err)
|
||||
}
|
||||
|
||||
// Should be 2 (3 total - 1 forgiven = 2)
|
||||
if count != 2 {
|
||||
t.Errorf("expected count=2 (3 total - 1 forgiven), got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCountUnforgivenNoShows_ExcludesOld tests that CountUnforgivenNoShows
|
||||
// excludes no-shows older than 6 months.
|
||||
func TestCountUnforgivenNoShows_ExcludesOld(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create test user
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Create 1 booking with status "no_show" from 7 months ago (should be excluded)
|
||||
oldStartTime := now.Add(-7 * 30 * 24 * time.Hour)
|
||||
var oldBookingID string
|
||||
err = db.DB.QueryRow(context.Background(), `
|
||||
INSERT INTO bookings (user_id, start_time, status, notes)
|
||||
VALUES ($1, $2, 'no_show', 'old no-show')
|
||||
RETURNING id
|
||||
`, userID, oldStartTime).Scan(&oldBookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create old booking: %v", err)
|
||||
}
|
||||
_, _ = db.DB.Exec(context.Background(),
|
||||
"INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)",
|
||||
oldBookingID, serviceID)
|
||||
|
||||
// Create 1 booking with status "no_show" from 1 month ago (should be included)
|
||||
recentStartTime := now.Add(-30 * 24 * time.Hour)
|
||||
var recentBookingID string
|
||||
err = db.DB.QueryRow(context.Background(), `
|
||||
INSERT INTO bookings (user_id, start_time, status, notes)
|
||||
VALUES ($1, $2, 'no_show', 'recent no-show')
|
||||
RETURNING id
|
||||
`, userID, recentStartTime).Scan(&recentBookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create recent booking: %v", err)
|
||||
}
|
||||
_, _ = db.DB.Exec(context.Background(),
|
||||
"INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)",
|
||||
recentBookingID, serviceID)
|
||||
|
||||
// Call CountUnforgivenNoShows directly
|
||||
count, err := CountUnforgivenNoShows(context.Background(), userID)
|
||||
if err != nil {
|
||||
t.Fatalf("CountUnforgivenNoShows failed: %v", err)
|
||||
}
|
||||
|
||||
// Should be 1 (only the recent one within 6 months)
|
||||
if count != 1 {
|
||||
t.Errorf("expected count=1 (only recent within 6 months), got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyDepositsIfNeeded_AppliesAt2Plus tests that ApplyDepositsIfNeeded
|
||||
// applies 3 deposits when user has 2 or more unforgiven no-shows in last 6 months.
|
||||
func TestApplyDepositsIfNeeded_AppliesAt2Plus(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create test user with deposits_required = 0
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
// Ensure deposits_required = 0
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set deposits_required: %v", err)
|
||||
}
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
// Create 2 bookings with status "no_show" within last 6 months
|
||||
now := time.Now()
|
||||
for i := 0; i < 2; i++ {
|
||||
startTime := now.Add(time.Duration(i*30) * 24 * time.Hour) // 0, 30 days ago
|
||||
var bookingID string
|
||||
err := db.DB.QueryRow(context.Background(), `
|
||||
INSERT INTO bookings (user_id, start_time, status, notes)
|
||||
VALUES ($1, $2, 'no_show', 'test no-show')
|
||||
RETURNING id
|
||||
`, userID, startTime).Scan(&bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking %d: %v", i, err)
|
||||
}
|
||||
|
||||
// Link service
|
||||
_, err = db.DB.Exec(context.Background(),
|
||||
"INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)",
|
||||
bookingID, serviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to link service: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Call ApplyDepositsIfNeeded directly
|
||||
applied, err := ApplyDepositsIfNeeded(context.Background(), userID)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyDepositsIfNeeded failed: %v", err)
|
||||
}
|
||||
|
||||
// Should return true (deposits were applied)
|
||||
if !applied {
|
||||
t.Error("expected ApplyDepositsIfNeeded to return true when 2+ no-shows")
|
||||
}
|
||||
|
||||
// Verify deposits_required = 3
|
||||
var deposits int
|
||||
err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits)
|
||||
if err != nil {
|
||||
t.Errorf("failed to query deposits: %v", err)
|
||||
}
|
||||
if deposits != 3 {
|
||||
t.Errorf("expected deposits_required=3, got %d", deposits)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyDepositsIfNeeded_DoesNotApplyAt1 tests that ApplyDepositsIfNeeded
|
||||
// does NOT apply deposits when user has only 1 unforgiven no-show.
|
||||
func TestApplyDepositsIfNeeded_DoesNotApplyAt1(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create test user with deposits_required = 0
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
// Ensure deposits_required = 0
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set deposits_required: %v", err)
|
||||
}
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
// Create 1 booking with status "no_show" within last 6 months
|
||||
startTime := time.Now().Add(-30 * 24 * time.Hour) // 30 days ago
|
||||
var bookingID string
|
||||
err = db.DB.QueryRow(context.Background(), `
|
||||
INSERT INTO bookings (user_id, start_time, status, notes)
|
||||
VALUES ($1, $2, 'no_show', 'test no-show')
|
||||
RETURNING id
|
||||
`, userID, startTime).Scan(&bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking: %v", err)
|
||||
}
|
||||
|
||||
// Link service
|
||||
_, err = db.DB.Exec(context.Background(),
|
||||
"INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)",
|
||||
bookingID, serviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to link service: %v", err)
|
||||
}
|
||||
|
||||
// Call ApplyDepositsIfNeeded directly
|
||||
applied, err := ApplyDepositsIfNeeded(context.Background(), userID)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyDepositsIfNeeded failed: %v", err)
|
||||
}
|
||||
|
||||
// Should return false (deposits were NOT applied)
|
||||
if applied {
|
||||
t.Error("expected ApplyDepositsIfNeeded to return false when only 1 no-show")
|
||||
}
|
||||
|
||||
// Verify deposits_required = 0 (unchanged)
|
||||
var deposits int
|
||||
err = db.DB.QueryRow(context.Background(), "SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&deposits)
|
||||
if err != nil {
|
||||
t.Errorf("failed to query deposits: %v", err)
|
||||
}
|
||||
if deposits != 0 {
|
||||
t.Errorf("expected deposits_required=0 (unchanged), got %d", deposits)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure test compilation - import pgxpool to avoid unused import
|
||||
var _ = func() *pgxpool.Pool { return nil }
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/color"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -29,6 +31,7 @@ import (
|
||||
"crussell/testutils/testdb"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/kovidgoyal/imaging"
|
||||
)
|
||||
|
||||
func setupTestDB(t *testing.T) func() {
|
||||
@@ -518,3 +521,91 @@ func TestPortfolio_Delete_Unauthenticated(t *testing.T) {
|
||||
t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// processImage EXIF Stripping Tests
|
||||
// =============================================================================
|
||||
|
||||
// TestPortfolio_ProcessImage_EXIFStripped verifies that processImage strips EXIF GPS data from images.
|
||||
func TestPortfolio_ProcessImage_EXIFStripped(t *testing.T) {
|
||||
// Create a simple test image using the standard library
|
||||
img := image.NewNRGBA(image.Rect(0, 0, 100, 100))
|
||||
grayColor := color.RGBA{R: 200, G: 200, B: 200, A: 255}
|
||||
for y := 0; y < 100; y++ {
|
||||
for x := 0; x < 100; x++ {
|
||||
img.Set(x, y, grayColor)
|
||||
}
|
||||
}
|
||||
|
||||
// Encode to JPEG bytes using imaging
|
||||
var buf bytes.Buffer
|
||||
err := imaging.Encode(&buf, img, imaging.JPEG, imaging.JPEGQuality(85))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode test image: %v", err)
|
||||
}
|
||||
imageBytes := buf.Bytes()
|
||||
|
||||
// Create a JPEG with embedded EXIF GPS data by appending GPS IFD marker after JPEG header
|
||||
// This creates a JPEG that claims to have GPS metadata
|
||||
gpsJpeg := createJpegWithExifMarker(imageBytes)
|
||||
if gpsJpeg == nil {
|
||||
t.Skip("Could not create JPEG with EXIF marker - using basic test")
|
||||
}
|
||||
|
||||
// Verify EXIF marker is present in the input
|
||||
hasExifBefore := bytes.Contains(gpsJpeg, []byte{0xFF, 0xE1}) // APP1 EXIF marker
|
||||
if !hasExifBefore {
|
||||
t.Skip("Could not inject EXIF marker - skipping GPS stripping test")
|
||||
}
|
||||
|
||||
// Process the image (this should strip EXIF/GPS data)
|
||||
result, err := processImage(gpsJpeg, 85)
|
||||
if err != nil {
|
||||
t.Fatalf("processImage failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify the result is a valid JPEG
|
||||
if len(result) == 0 {
|
||||
t.Fatal("processImage returned empty result")
|
||||
}
|
||||
|
||||
// Verify EXIF marker is NOT present in the output
|
||||
hasExifAfter := bytes.Contains(result, []byte{0xFF, 0xE1})
|
||||
if hasExifAfter {
|
||||
t.Error("EXIF data was NOT stripped by processImage - metadata still present")
|
||||
}
|
||||
}
|
||||
|
||||
// createJpegWithExifMarker creates a JPEG with an APP1 EXIF marker inserted after the SOI marker
|
||||
func createJpegWithExifMarker(jpegData []byte) []byte {
|
||||
if len(jpegData) < 2 || jpegData[0] != 0xFF || jpegData[1] != 0xD8 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create a minimal APP1 EXIF marker with GPS IFD tag (0x8825)
|
||||
// APP1 marker: FF E1, length, "Exif\0\0", byte order, magic, IFD offset
|
||||
app1 := []byte{
|
||||
0xFF, 0xE1, // APP1 marker
|
||||
0x00, 0x22, // Length: 34 bytes
|
||||
// Exif header
|
||||
0x45, 0x78, 0x69, 0x66, 0x00, 0x00, // "Exif\0\0"
|
||||
0x49, 0x49, // Byte order: little-endian
|
||||
0x2A, 0x00, // Magic number
|
||||
0x08, 0x00, 0x00, 0x00, // Offset to first IFD
|
||||
// Main IFD with GPS IFD pointer
|
||||
0x01, 0x00, // Number of entries: 1
|
||||
0x25, 0x88, // GPS IFD tag (0x8825)
|
||||
0x04, 0x00, // Type: LONG
|
||||
0x01, 0x00, 0x00, 0x00, // Count: 1
|
||||
0x10, 0x00, 0x00, 0x00, // Offset: 16 (to GPS IFD)
|
||||
0x00, 0x00, 0x00, 0x00, // Next IFD: none
|
||||
}
|
||||
|
||||
// Insert APP1 after SOI marker (FF D8)
|
||||
result := make([]byte, 0, len(jpegData)+len(app1))
|
||||
result = append(result, jpegData[:2]...)
|
||||
result = append(result, app1...)
|
||||
result = append(result, jpegData[2:]...)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -696,6 +696,474 @@ func TestCleanupOldReservations(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests for CleanupOldReservations (Admin Walk-In) ---
|
||||
|
||||
// TestCleanupOldReservations_AdminWalkIn verifies that admin walk-in reservations
|
||||
// older than 15 minutes are deleted, while recent ones are preserved.
|
||||
func TestCleanupOldReservations_AdminWalkIn(t *testing.T) {
|
||||
cleanup := setupTimeBlockersTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
ukLocation, _ := time.LoadLocation("Europe/London")
|
||||
|
||||
// Create old walk-in reservation (>15 min old)
|
||||
oldTime := time.Now().Add(-16 * time.Minute).In(ukLocation)
|
||||
_, err := db.DB.Exec(ctx, `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
||||
VALUES ($1, 60, 'RESERVATION:admin:walkin:guest:123', $2)
|
||||
`, oldTime, time.Now().Add(-16*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create old walk-in reservation: %v", err)
|
||||
}
|
||||
|
||||
// Create recent walk-in reservation (<15 min old)
|
||||
recentTime := time.Now().Add(-14 * time.Minute).In(ukLocation)
|
||||
_, err = db.DB.Exec(ctx, `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
||||
VALUES ($1, 60, 'RESERVATION:admin:walkin:guest:456', $2)
|
||||
`, recentTime, time.Now().Add(-14*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create recent walk-in reservation: %v", err)
|
||||
}
|
||||
|
||||
// Run cleanup
|
||||
err = CleanupOldReservations(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CleanupOldReservations failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify old reservation was deleted
|
||||
var oldCount int
|
||||
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:walkin:guest:123'").Scan(&oldCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to check old reservation: %v", err)
|
||||
}
|
||||
if oldCount != 0 {
|
||||
t.Error("expected old walk-in reservation (16 min) to be deleted")
|
||||
}
|
||||
|
||||
// Verify recent reservation still exists
|
||||
var recentCount int
|
||||
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:walkin:guest:456'").Scan(&recentCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to check recent reservation: %v", err)
|
||||
}
|
||||
if recentCount != 1 {
|
||||
t.Error("expected recent walk-in reservation (14 min) to be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests for CleanupOldReservations (Admin Call-In) ---
|
||||
|
||||
// TestCleanupOldReservations_AdminCallIn verifies that admin call-in reservations
|
||||
// older than 15 minutes are deleted, while recent ones are preserved.
|
||||
func TestCleanupOldReservations_AdminCallIn(t *testing.T) {
|
||||
cleanup := setupTimeBlockersTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
ukLocation, _ := time.LoadLocation("Europe/London")
|
||||
|
||||
// Create old call-in reservation (>15 min old)
|
||||
oldTime := time.Now().Add(-16 * time.Minute).In(ukLocation)
|
||||
_, err := db.DB.Exec(ctx, `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
||||
VALUES ($1, 60, 'RESERVATION:admin:callin:guest:123', $2)
|
||||
`, oldTime, time.Now().Add(-16*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create old call-in reservation: %v", err)
|
||||
}
|
||||
|
||||
// Create recent call-in reservation (<15 min old)
|
||||
recentTime := time.Now().Add(-14 * time.Minute).In(ukLocation)
|
||||
_, err = db.DB.Exec(ctx, `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
||||
VALUES ($1, 60, 'RESERVATION:admin:callin:guest:456', $2)
|
||||
`, recentTime, time.Now().Add(-14*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create recent call-in reservation: %v", err)
|
||||
}
|
||||
|
||||
// Run cleanup
|
||||
err = CleanupOldReservations(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CleanupOldReservations failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify old reservation was deleted
|
||||
var oldCount int
|
||||
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:callin:guest:123'").Scan(&oldCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to check old reservation: %v", err)
|
||||
}
|
||||
if oldCount != 0 {
|
||||
t.Error("expected old call-in reservation (16 min) to be deleted")
|
||||
}
|
||||
|
||||
// Verify recent reservation still exists
|
||||
var recentCount int
|
||||
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:callin:guest:456'").Scan(&recentCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to check recent reservation: %v", err)
|
||||
}
|
||||
if recentCount != 1 {
|
||||
t.Error("expected recent call-in reservation (14 min) to be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests for CleanupOldReservations (Mixed Types) ---
|
||||
|
||||
// TestCleanupOldReservations_MixedTypes verifies that cleanup correctly handles
|
||||
// all reservation types with their respective TTLs.
|
||||
func TestCleanupOldReservations_MixedTypes(t *testing.T) {
|
||||
cleanup := setupTimeBlockersTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
ukLocation, _ := time.LoadLocation("Europe/London")
|
||||
|
||||
// Create old user reservation (>1 hour old)
|
||||
oldUserTime := time.Now().Add(-2 * time.Hour).In(ukLocation)
|
||||
_, err := db.DB.Exec(ctx, `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
||||
VALUES ($1, 60, 'RESERVATION:user:old', $2)
|
||||
`, oldUserTime, time.Now().Add(-2*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create old user reservation: %v", err)
|
||||
}
|
||||
|
||||
// Create recent user reservation (<1 hour old)
|
||||
recentUserTime := time.Now().Add(-30 * time.Minute).In(ukLocation)
|
||||
_, err = db.DB.Exec(ctx, `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
||||
VALUES ($1, 60, 'RESERVATION:user:recent', $2)
|
||||
`, recentUserTime, time.Now().Add(-30*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create recent user reservation: %v", err)
|
||||
}
|
||||
|
||||
// Create old anon reservation (>10 min old)
|
||||
oldAnonTime := time.Now().Add(-15 * time.Minute).In(ukLocation)
|
||||
_, err = db.DB.Exec(ctx, `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
||||
VALUES ($1, 60, 'RESERVATION:anon:old', $2)
|
||||
`, oldAnonTime, time.Now().Add(-15*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create old anon reservation: %v", err)
|
||||
}
|
||||
|
||||
// Create recent anon reservation (<10 min old)
|
||||
recentAnonTime := time.Now().Add(-5 * time.Minute).In(ukLocation)
|
||||
_, err = db.DB.Exec(ctx, `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
||||
VALUES ($1, 60, 'RESERVATION:anon:recent', $2)
|
||||
`, recentAnonTime, time.Now().Add(-5*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create recent anon reservation: %v", err)
|
||||
}
|
||||
|
||||
// Create old admin walk-in reservation (>15 min old)
|
||||
oldWalkinTime := time.Now().Add(-20 * time.Minute).In(ukLocation)
|
||||
_, err = db.DB.Exec(ctx, `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
||||
VALUES ($1, 60, 'RESERVATION:admin:walkin:old', $2)
|
||||
`, oldWalkinTime, time.Now().Add(-20*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create old walk-in reservation: %v", err)
|
||||
}
|
||||
|
||||
// Create recent admin walk-in reservation (<15 min old)
|
||||
recentWalkinTime := time.Now().Add(-10 * time.Minute).In(ukLocation)
|
||||
_, err = db.DB.Exec(ctx, `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
||||
VALUES ($1, 60, 'RESERVATION:admin:walkin:recent', $2)
|
||||
`, recentWalkinTime, time.Now().Add(-10*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create recent walk-in reservation: %v", err)
|
||||
}
|
||||
|
||||
// Create old admin call-in reservation (>15 min old)
|
||||
oldCallinTime := time.Now().Add(-20 * time.Minute).In(ukLocation)
|
||||
_, err = db.DB.Exec(ctx, `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
||||
VALUES ($1, 60, 'RESERVATION:admin:callin:old', $2)
|
||||
`, oldCallinTime, time.Now().Add(-20*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create old call-in reservation: %v", err)
|
||||
}
|
||||
|
||||
// Create recent admin call-in reservation (<15 min old)
|
||||
recentCallinTime := time.Now().Add(-10 * time.Minute).In(ukLocation)
|
||||
_, err = db.DB.Exec(ctx, `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
||||
VALUES ($1, 60, 'RESERVATION:admin:callin:recent', $2)
|
||||
`, recentCallinTime, time.Now().Add(-10*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create recent call-in reservation: %v", err)
|
||||
}
|
||||
|
||||
// Verify we have 8 reservations before cleanup
|
||||
var countBefore int
|
||||
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:%'").Scan(&countBefore)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count reservations before cleanup: %v", err)
|
||||
}
|
||||
if countBefore != 8 {
|
||||
t.Errorf("expected 8 reservations before cleanup, got %d", countBefore)
|
||||
}
|
||||
|
||||
// Run cleanup
|
||||
err = CleanupOldReservations(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CleanupOldReservations failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify old reservations were deleted (4 old ones)
|
||||
var oldUserCount, oldAnonCount, oldWalkinCount, oldCallinCount int
|
||||
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:user:old'").Scan(&oldUserCount)
|
||||
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:anon:old'").Scan(&oldAnonCount)
|
||||
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:walkin:old'").Scan(&oldWalkinCount)
|
||||
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:callin:old'").Scan(&oldCallinCount)
|
||||
|
||||
if oldUserCount != 0 {
|
||||
t.Error("expected old user reservation to be deleted")
|
||||
}
|
||||
if oldAnonCount != 0 {
|
||||
t.Error("expected old anon reservation to be deleted")
|
||||
}
|
||||
if oldWalkinCount != 0 {
|
||||
t.Error("expected old walk-in reservation to be deleted")
|
||||
}
|
||||
if oldCallinCount != 0 {
|
||||
t.Error("expected old call-in reservation to be deleted")
|
||||
}
|
||||
|
||||
// Verify recent reservations still exist (4 recent ones)
|
||||
var recentUserCount, recentAnonCount, recentWalkinCount, recentCallinCount int
|
||||
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:user:recent'").Scan(&recentUserCount)
|
||||
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:anon:recent'").Scan(&recentAnonCount)
|
||||
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:walkin:recent'").Scan(&recentWalkinCount)
|
||||
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:callin:recent'").Scan(&recentCallinCount)
|
||||
|
||||
if recentUserCount != 1 {
|
||||
t.Error("expected recent user reservation to be preserved")
|
||||
}
|
||||
if recentAnonCount != 1 {
|
||||
t.Error("expected recent anon reservation to be preserved")
|
||||
}
|
||||
if recentWalkinCount != 1 {
|
||||
t.Error("expected recent walk-in reservation to be preserved")
|
||||
}
|
||||
if recentCallinCount != 1 {
|
||||
t.Error("expected recent call-in reservation to be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests for GetTimeBlockersInRange (Excludes Reservations) ---
|
||||
|
||||
// TestGetTimeBlockersInRange_ExcludesReservations verifies that reservation
|
||||
// blockers are excluded from the results.
|
||||
func TestGetTimeBlockersInRange_ExcludesReservations(t *testing.T) {
|
||||
cleanup := setupTimeBlockersTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
ukLocation, _ := time.LoadLocation("Europe/London")
|
||||
|
||||
// Create a regular blocker for tomorrow at 10:00
|
||||
tomorrow := time.Now().Add(24 * time.Hour).In(ukLocation)
|
||||
blockerTime := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, ukLocation)
|
||||
_, err := db.DB.Exec(ctx, `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
||||
VALUES ($1, 60, 'Staff meeting', NULL)
|
||||
`, blockerTime)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create regular blocker: %v", err)
|
||||
}
|
||||
|
||||
// Create a reservation for tomorrow at 11:00
|
||||
reservationTime := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 11, 0, 0, 0, ukLocation)
|
||||
_, err = db.DB.Exec(ctx, `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
||||
VALUES ($1, 60, 'RESERVATION:user:abc:123', NULL)
|
||||
`, reservationTime)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create reservation: %v", err)
|
||||
}
|
||||
|
||||
// Query range covering both times
|
||||
start := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 0, 0, 0, 0, ukLocation)
|
||||
end := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 23, 59, 59, 0, ukLocation)
|
||||
|
||||
blockers, err := GetTimeBlockersInRange(ctx, start, end)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTimeBlockersInRange failed: %v", err)
|
||||
}
|
||||
|
||||
// Should return only 1 blocker (the regular one, not the reservation)
|
||||
if len(blockers) != 1 {
|
||||
t.Errorf("expected 1 blocker, got %d", len(blockers))
|
||||
}
|
||||
|
||||
// Verify the blocker is "Staff meeting"
|
||||
if len(blockers) > 0 && blockers[0].Description != "Staff meeting" {
|
||||
t.Errorf("expected blocker 'Staff meeting', got '%s'", blockers[0].Description)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests for AnonymizeStaleGuestAccounts ---
|
||||
|
||||
// TestAnonymizeStaleGuestAccounts_Exactly6Months verifies that a guest with
|
||||
// a booking exactly 6 months ago is anonymized.
|
||||
func TestAnonymizeStaleGuestAccounts_Exactly6Months(t *testing.T) {
|
||||
cleanup := setupTimeBlockersTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create guest user
|
||||
guestID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create guest user: %v", err)
|
||||
}
|
||||
|
||||
// Set account_role to guest
|
||||
_, err = db.DB.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set guest role: %v", err)
|
||||
}
|
||||
|
||||
// Create booking with start_time exactly 6 months ago
|
||||
_, err = db.DB.Exec(ctx, `
|
||||
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
||||
VALUES ($1, NOW() - INTERVAL '6 months', 'completed', false)
|
||||
`, guestID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking: %v", err)
|
||||
}
|
||||
|
||||
// Run anonymization
|
||||
err = AnonymizeStaleGuestAccounts(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify guest was anonymized
|
||||
var firstName, lastName, email string
|
||||
err = db.DB.QueryRow(ctx, `SELECT n_first_name, n_last_name, email FROM users WHERE id = $1`, guestID).Scan(&firstName, &lastName, &email)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query anonymized user: %v", err)
|
||||
}
|
||||
|
||||
if firstName != "Guest" {
|
||||
t.Errorf("expected first_name 'Guest', got '%s'", firstName)
|
||||
}
|
||||
if lastName != "Anonymized" {
|
||||
t.Errorf("expected last_name 'Anonymized', got '%s'", lastName)
|
||||
}
|
||||
if !strings.HasPrefix(email, "anon-") {
|
||||
t.Errorf("expected email to start with 'anon-', got '%s'", email)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnonymizeStaleGuestAccounts_ActiveBooking_Skipped verifies that a guest
|
||||
// with an active (future) booking is NOT anonymized even if they have a past booking.
|
||||
func TestAnonymizeStaleGuestAccounts_ActiveBooking_Skipped(t *testing.T) {
|
||||
cleanup := setupTimeBlockersTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create guest user
|
||||
guestID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create guest user: %v", err)
|
||||
}
|
||||
|
||||
// Set account_role to guest
|
||||
_, err = db.DB.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set guest role: %v", err)
|
||||
}
|
||||
|
||||
// Create past booking (7 months ago)
|
||||
_, err = db.DB.Exec(ctx, `
|
||||
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
||||
VALUES ($1, NOW() - INTERVAL '7 months', 'completed', false)
|
||||
`, guestID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create past booking: %v", err)
|
||||
}
|
||||
|
||||
// Create active booking (tomorrow)
|
||||
tomorrow := time.Now().Add(24 * time.Hour)
|
||||
_, err = db.DB.Exec(ctx, `
|
||||
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
||||
VALUES ($1, $2, 'confirmed', false)
|
||||
`, guestID, tomorrow)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create active booking: %v", err)
|
||||
}
|
||||
|
||||
// Run anonymization
|
||||
err = AnonymizeStaleGuestAccounts(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify guest was NOT anonymized
|
||||
var firstName string
|
||||
err = db.DB.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, guestID).Scan(&firstName)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query user: %v", err)
|
||||
}
|
||||
|
||||
// The first name should NOT be "Guest" (it should retain original name)
|
||||
if firstName == "Guest" {
|
||||
t.Error("expected guest with active booking to NOT be anonymized")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnonymizeStaleGuestAccounts_NoBookings verifies that a guest with
|
||||
// no bookings is NOT anonymized.
|
||||
func TestAnonymizeStaleGuestAccounts_NoBookings(t *testing.T) {
|
||||
cleanup := setupTimeBlockersTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create guest user with no bookings
|
||||
guestID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create guest user: %v", err)
|
||||
}
|
||||
|
||||
// Set account_role to guest
|
||||
_, err = db.DB.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set guest role: %v", err)
|
||||
}
|
||||
|
||||
// Run anonymization
|
||||
err = AnonymizeStaleGuestAccounts(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify guest was NOT anonymized
|
||||
var firstName string
|
||||
err = db.DB.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, guestID).Scan(&firstName)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query user: %v", err)
|
||||
}
|
||||
|
||||
// The first name should NOT be "Guest" (it should retain original name)
|
||||
if firstName == "Guest" {
|
||||
t.Error("expected guest with no bookings to NOT be anonymized")
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure pool is used to avoid unused import error
|
||||
var _ = pgxpool.Pool{}
|
||||
var _ = bytes.Buffer{}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//go:build test
|
||||
// +build test
|
||||
|
||||
package user
|
||||
|
||||
// Package user contains tests for guest user creation endpoints.
|
||||
//
|
||||
// Test Coverage:
|
||||
// - CreateGuestUserHandler: POST /api/user/guest - Create a new guest user
|
||||
//
|
||||
// Edge case tests for validation:
|
||||
// - Invalid phone number format
|
||||
// - Empty first name
|
||||
// - Name exceeds 50 character limit
|
||||
// - Invalid email format
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"crussell/testutils/jwt"
|
||||
)
|
||||
|
||||
// TestGuestUser_Create_InvalidPhone verifies that an invalid phone number returns 400 Bad Request.
|
||||
func TestGuestUser_Create_InvalidPhone(t *testing.T) {
|
||||
cleanup, _ := setupTest(t)
|
||||
defer cleanup()
|
||||
|
||||
jwt.Init()
|
||||
|
||||
reqBody := CreateGuestUserRequest{
|
||||
FirstName: "Test",
|
||||
LastName: "User",
|
||||
Email: "test@test.com",
|
||||
Phone: "not-a-phone",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/user/guest", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
CreateGuestUserHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d", rr.Code)
|
||||
t.Logf("response body: %s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuestUser_Create_EmptyFirstName verifies that an empty first name returns 400 Bad Request.
|
||||
func TestGuestUser_Create_EmptyFirstName(t *testing.T) {
|
||||
cleanup, _ := setupTest(t)
|
||||
defer cleanup()
|
||||
|
||||
jwt.Init()
|
||||
|
||||
reqBody := CreateGuestUserRequest{
|
||||
FirstName: "",
|
||||
LastName: "User",
|
||||
Email: "test@test.com",
|
||||
Phone: "07123456789",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/user/guest", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
CreateGuestUserHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d", rr.Code)
|
||||
t.Logf("response body: %s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuestUser_Create_NameTooLong verifies that a first name exceeding 50 characters returns 400 Bad Request.
|
||||
func TestGuestUser_Create_NameTooLong(t *testing.T) {
|
||||
cleanup, _ := setupTest(t)
|
||||
defer cleanup()
|
||||
|
||||
jwt.Init()
|
||||
|
||||
reqBody := CreateGuestUserRequest{
|
||||
FirstName: strings.Repeat("a", 51),
|
||||
LastName: "User",
|
||||
Email: "test@test.com",
|
||||
Phone: "07123456789",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/user/guest", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
CreateGuestUserHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d", rr.Code)
|
||||
t.Logf("response body: %s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuestUser_Create_InvalidEmail verifies that an invalid email format returns 400 Bad Request.
|
||||
func TestGuestUser_Create_InvalidEmail(t *testing.T) {
|
||||
cleanup, _ := setupTest(t)
|
||||
defer cleanup()
|
||||
|
||||
jwt.Init()
|
||||
|
||||
reqBody := CreateGuestUserRequest{
|
||||
FirstName: "Test",
|
||||
LastName: "User",
|
||||
Email: "not-an-email",
|
||||
Phone: "07123456789",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/user/guest", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
CreateGuestUserHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d", rr.Code)
|
||||
t.Logf("response body: %s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -596,3 +596,86 @@ func TestProfile_UploadPicture(t *testing.T) {
|
||||
t.Error("expected url in response")
|
||||
}
|
||||
}
|
||||
|
||||
// TestContactInfo_ReturnsAdmin verifies that GetContactInfoHandler returns contact info for the first admin user.
|
||||
func TestContactInfo_ReturnsAdmin(t *testing.T) {
|
||||
cleanup, pool := setupTest(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create admin user with profile data
|
||||
adminID, err := fixtures.CreateTestAdminUser(pool)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
|
||||
// Update admin with specific profile data
|
||||
_, err = pool.Exec(context.Background(), `
|
||||
UPDATE users
|
||||
SET n_first_name = 'Jane', n_last_name = 'Smith', phone = '+447700900000', email = 'jane@example.com'
|
||||
WHERE id = $1
|
||||
`, adminID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to update admin profile: %v", err)
|
||||
}
|
||||
|
||||
// Call handler directly (no auth needed - public endpoint)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/contact", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
GetContactInfoHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", rr.Code)
|
||||
t.Logf("response body: %s", rr.Body.String())
|
||||
return
|
||||
}
|
||||
|
||||
var contact ContactInfo
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &contact); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
expectedName := "Jane Smith"
|
||||
if contact.Name != expectedName {
|
||||
t.Errorf("expected name %q, got %q", expectedName, contact.Name)
|
||||
}
|
||||
|
||||
expectedEmail := "jane@example.com"
|
||||
if contact.Email != expectedEmail {
|
||||
t.Errorf("expected email %q, got %q", expectedEmail, contact.Email)
|
||||
}
|
||||
|
||||
expectedPhone := "+447700900000"
|
||||
if contact.Phone != expectedPhone {
|
||||
t.Errorf("expected phone %q, got %q", expectedPhone, contact.Phone)
|
||||
}
|
||||
|
||||
expectedRole := "Owner / Beauty Specialist"
|
||||
if contact.Role != expectedRole {
|
||||
t.Errorf("expected role %q, got %q", expectedRole, contact.Role)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContactInfo_NoAdmin verifies that GetContactInfoHandler returns 404 when no admin exists.
|
||||
func TestContactInfo_NoAdmin(t *testing.T) {
|
||||
cleanup, pool := setupTest(t)
|
||||
defer cleanup()
|
||||
|
||||
// Ensure no admin users exist - truncate tables
|
||||
testdb.TruncateTables(t, pool)
|
||||
|
||||
// Create only a regular user (not admin)
|
||||
_, err := fixtures.CreateTestUser(pool)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
// Call handler
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/contact", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
GetContactInfoHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Errorf("expected status 404, got %d", rr.Code)
|
||||
t.Logf("response body: %s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
//go:build test
|
||||
// +build test
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/testutils"
|
||||
)
|
||||
|
||||
func TestHealthCheck_OK(t *testing.T) {
|
||||
// Setup test database
|
||||
cleanup := testutils.SetupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create request and recorder
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Call handler directly
|
||||
healthCheckHandler(w, req)
|
||||
|
||||
// Assert 200 OK
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status %d, got %d. body: %s", http.StatusOK, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Parse JSON response
|
||||
var response map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("failed to parse JSON response: %v", err)
|
||||
}
|
||||
|
||||
// Assert status == "ok"
|
||||
status, ok := response["status"].(string)
|
||||
if !ok || status != "ok" {
|
||||
t.Errorf("expected status 'ok', got '%v'", response["status"])
|
||||
}
|
||||
|
||||
// Assert services
|
||||
services, ok := response["services"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("services not found in response")
|
||||
}
|
||||
|
||||
// Assert services.backend == "ok"
|
||||
backend, ok := services["backend"].(string)
|
||||
if !ok || backend != "ok" {
|
||||
t.Errorf("expected services.backend 'ok', got '%v'", services["backend"])
|
||||
}
|
||||
|
||||
// Assert services.database == "ok"
|
||||
database, ok := services["database"].(string)
|
||||
if !ok || database != "ok" {
|
||||
t.Errorf("expected services.database 'ok', got '%v'", services["database"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthCheck_Degraded(t *testing.T) {
|
||||
// Setup test database
|
||||
cleanup := testutils.SetupTestDB(t)
|
||||
|
||||
// Save original db.DB and set to nil to simulate degraded state
|
||||
originalDB := db.DB
|
||||
db.DB = nil
|
||||
|
||||
// Create request and recorder
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Call handler directly
|
||||
healthCheckHandler(w, req)
|
||||
|
||||
// Assert 503 Service Unavailable
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected status %d, got %d. body: %s", http.StatusServiceUnavailable, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Parse JSON response
|
||||
var response map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("failed to parse JSON response: %v", err)
|
||||
}
|
||||
|
||||
// Assert status == "degraded"
|
||||
status, ok := response["status"].(string)
|
||||
if !ok || status != "degraded" {
|
||||
t.Errorf("expected status 'degraded', got '%v'", response["status"])
|
||||
}
|
||||
|
||||
// Assert services
|
||||
services, ok := response["services"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("services not found in response")
|
||||
}
|
||||
|
||||
// Assert services.database == "error"
|
||||
database, ok := services["database"].(string)
|
||||
if !ok || database != "error" {
|
||||
t.Errorf("expected services.database 'error', got '%v'", services["database"])
|
||||
}
|
||||
|
||||
// Restore original db.DB and cleanup
|
||||
db.DB = originalDB
|
||||
cleanup()
|
||||
}
|
||||
Vendored
+13
-8
@@ -4,17 +4,21 @@
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "c3304ad1f15b3261",
|
||||
"id": "88f82a6e53bb7e58",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "b72bbfe2e0c01c0c",
|
||||
"id": "0e456d61bc5b6ded",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "empty",
|
||||
"state": {},
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "Crussell/Test Implementation Plan.md",
|
||||
"mode": "source",
|
||||
"source": false
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "New tab"
|
||||
"title": "Test Implementation Plan"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -70,8 +74,7 @@
|
||||
"title": "Bookmarks"
|
||||
}
|
||||
}
|
||||
],
|
||||
"currentTab": 1
|
||||
]
|
||||
}
|
||||
],
|
||||
"direction": "horizontal",
|
||||
@@ -166,8 +169,10 @@
|
||||
"bases:Create new base": false
|
||||
}
|
||||
},
|
||||
"active": "b72bbfe2e0c01c0c",
|
||||
"active": "0e456d61bc5b6ded",
|
||||
"lastOpenFiles": [
|
||||
"Crussell/Future Work - Gap Backlog.md",
|
||||
"Crussell/Test Implementation Plan.md",
|
||||
"Crussell/Crussell Nails.md",
|
||||
"Crussell/Backend/bookings.md",
|
||||
"Untitled.base",
|
||||
|
||||
Reference in New Issue
Block a user