CI / Nginx config check (push) Successful in 13s
CI / Env docs check (push) Successful in 15s
CI / Docker compose check (push) Successful in 15s
CI / Frontend major deps (push) Failing after 24s
CI / Frontend deps check (push) Successful in 30s
CI / Secrets scan (push) Successful in 38s
CI / Go build (push) Successful in 39s
CI / Frontend build (push) Successful in 1m3s
CI / Knip (push) Successful in 45s
CI / Go vet (prod) (push) Failing after 1m42s
CI / Frontend a11y check (push) Successful in 2m34s
CI / Go vet (dev) (push) Successful in 2m29s
CI / Staticcheck (prod) (push) Failing after 2m38s
CI / go mod tidy (push) Successful in 1m3s
CI / Staticcheck (dev) (push) Successful in 2m55s
CI / Frontend QC (audit) (push) Successful in 51s
CI / golangci-lint (push) Successful in 3m22s
CI / Go vulnerabilities (push) Successful in 1m26s
CI / Frontend QC (typecheck) (push) Successful in 2m18s
CI / Security scan (prod) (push) Successful in 4m18s
CI / Security scan (dev) (push) Successful in 4m40s
CI / Tests (prod) (push) Has been skipped
CI / Tests (dev) (push) Has been skipped
CI / Race (prod) (push) Has been skipped
CI / Race (dev) (push) Has been skipped
CI / Frontend QC (lint) (push) Successful in 2m18s
CI / Svelte strict check (push) Successful in 43s
New test files cover previously untested paths across DAV, validators, S3, Square, mw, bookings, user, and payments packages. Includes mock fix: HoldCheckouts flag on MockClient allows tests to pause auto-complete goroutine for testing PENDING checkout states. Coverage: 50.4% → 65.0% (+14.6pp)
330 lines
9.4 KiB
Go
330 lines
9.4 KiB
Go
//go:build test
|
|
|
|
package user
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"crussell/db"
|
|
"crussell/testutils"
|
|
"crussell/testutils/fixtures"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
func TestCustomerRelationship_Success(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
|
|
svc1, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service 1: %v", err)
|
|
}
|
|
svc2ID, err := createService(ctx, tx, "Gel Manicure", 35.00)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service 2: %v", err)
|
|
}
|
|
|
|
booking1 := createCompletedBooking(t, ctx, tx, userID, svc1, "2024-01-15 10:00:00+00", 50.00)
|
|
booking2 := createCompletedBooking(t, ctx, tx, userID, svc1, "2024-06-20 14:00:00+00", 50.00)
|
|
booking3 := createCompletedBooking(t, ctx, tx, userID, svc2ID, "2024-12-01 11:00:00+00", 35.00)
|
|
|
|
createPayment(t, ctx, tx, booking1, "full", 50.00)
|
|
createPayment(t, ctx, tx, booking2, "full", 50.00)
|
|
createPayment(t, ctx, tx, booking3, "full", 35.00)
|
|
|
|
req := newAdminRequest("GET", "/api/admin/users/"+userID+"/relationship", userID, ctx)
|
|
rr := httptest.NewRecorder()
|
|
GetCustomerRelationshipHandler(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d. body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var result CustomerRelationship
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if result.TotalSpend != 135.00 {
|
|
t.Errorf("expected total spend 135.00, got %.2f", result.TotalSpend)
|
|
}
|
|
|
|
if result.TotalTips != 0 {
|
|
t.Errorf("expected total tips 0, got %.2f", result.TotalTips)
|
|
}
|
|
|
|
if result.TotalVisits != 3 {
|
|
t.Errorf("expected total visits 3, got %d", result.TotalVisits)
|
|
}
|
|
|
|
if result.CustomerFor == "" {
|
|
t.Error("expected customerFor to be set")
|
|
}
|
|
|
|
if result.FirstVisitDate == nil {
|
|
t.Fatal("expected firstVisitDate to be set")
|
|
}
|
|
if !strings.Contains(*result.FirstVisitDate, "2024-01-15") {
|
|
t.Errorf("expected first visit date to contain 2024-01-15, got %s", *result.FirstVisitDate)
|
|
}
|
|
|
|
if result.LastVisitDate == nil {
|
|
t.Fatal("expected lastVisitDate to be set")
|
|
}
|
|
if !strings.Contains(*result.LastVisitDate, "2024-12-01") {
|
|
t.Errorf("expected last visit date to contain 2024-12-01, got %s", *result.LastVisitDate)
|
|
}
|
|
|
|
if len(result.TopServices) != 2 {
|
|
t.Fatalf("expected 2 top services, got %d", len(result.TopServices))
|
|
}
|
|
if result.TopServices[0].Count != 2 {
|
|
t.Errorf("expected top service count 2, got %d", result.TopServices[0].Count)
|
|
}
|
|
if result.TopServices[1].Count != 1 {
|
|
t.Errorf("expected second service count 1, got %d", result.TopServices[1].Count)
|
|
}
|
|
}
|
|
|
|
func TestCustomerRelationship_NoBookings(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
|
|
req := newAdminRequest("GET", "/api/admin/users/"+userID+"/relationship", userID, ctx)
|
|
rr := httptest.NewRecorder()
|
|
GetCustomerRelationshipHandler(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d. body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var result CustomerRelationship
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if result.TotalSpend != 0 {
|
|
t.Errorf("expected total spend 0, got %.2f", result.TotalSpend)
|
|
}
|
|
if result.TotalTips != 0 {
|
|
t.Errorf("expected total tips 0, got %.2f", result.TotalTips)
|
|
}
|
|
if result.TotalVisits != 0 {
|
|
t.Errorf("expected total visits 0, got %d", result.TotalVisits)
|
|
}
|
|
if result.CustomerFor != "" {
|
|
t.Errorf("expected empty customerFor, got %s", result.CustomerFor)
|
|
}
|
|
if result.FirstVisitDate != nil {
|
|
t.Errorf("expected firstVisitDate to be nil, got %s", *result.FirstVisitDate)
|
|
}
|
|
if result.LastVisitDate != nil {
|
|
t.Errorf("expected lastVisitDate to be nil, got %s", *result.LastVisitDate)
|
|
}
|
|
if len(result.TopServices) != 0 {
|
|
t.Errorf("expected empty top services, got %d items", len(result.TopServices))
|
|
}
|
|
}
|
|
|
|
func TestCustomerRelationship_UserNotFound(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
req := newAdminRequest("GET", "/api/admin/users/000000000000/relationship", "000000000000", context.Background())
|
|
rr := httptest.NewRecorder()
|
|
GetCustomerRelationshipHandler(rr, req)
|
|
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Errorf("expected status 404, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestCustomerRelationship_InvalidID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
id string
|
|
}{
|
|
{"too_short", "abc"},
|
|
{"too_long", "abcdef1234567"},
|
|
{"empty", ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := newAdminRequest("GET", "/api/admin/users/"+tt.id+"/relationship", tt.id, context.Background())
|
|
rr := httptest.NewRecorder()
|
|
GetCustomerRelationshipHandler(rr, req)
|
|
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Errorf("expected status 404 for id %q, got %d", tt.id, rr.Code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCustomerRelationship_OnlyPendingBookings(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
|
|
svcID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBooking(tx, userID, svcID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
_ = bookingID
|
|
|
|
req := newAdminRequest("GET", "/api/admin/users/"+userID+"/relationship", userID, ctx)
|
|
rr := httptest.NewRecorder()
|
|
GetCustomerRelationshipHandler(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d. body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var result CustomerRelationship
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if result.TotalVisits != 0 {
|
|
t.Errorf("expected 0 visits (pending booking), got %d", result.TotalVisits)
|
|
}
|
|
if result.TotalSpend != 0 {
|
|
t.Errorf("expected 0 spend (no completed payments), got %.2f", result.TotalSpend)
|
|
}
|
|
if result.TotalTips != 0 {
|
|
t.Errorf("expected 0 tips, got %.2f", result.TotalTips)
|
|
}
|
|
}
|
|
|
|
func TestCustomerRelationship_PartialPayments(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
|
|
svcID, err := createService(ctx, tx, "Test Service", 100.00)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID := createCompletedBooking(t, ctx, tx, userID, svcID, "2024-03-01 10:00:00+00", 100.00)
|
|
|
|
createPayment(t, ctx, tx, bookingID, "full", 80.00)
|
|
createPayment(t, ctx, tx, bookingID, "tip", 10.00)
|
|
createPayment(t, ctx, tx, bookingID, "deposit", 20.00)
|
|
|
|
req := newAdminRequest("GET", "/api/admin/users/"+userID+"/relationship", userID, ctx)
|
|
rr := httptest.NewRecorder()
|
|
GetCustomerRelationshipHandler(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d. body: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var result CustomerRelationship
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if result.TotalSpend != 100.00 {
|
|
t.Errorf("expected total spend 100.00 (full + deposit), got %.2f", result.TotalSpend)
|
|
}
|
|
|
|
if result.TotalTips != 10.00 {
|
|
t.Errorf("expected total tips 10.00, got %.2f", result.TotalTips)
|
|
}
|
|
}
|
|
|
|
func newAdminRequest(method, path, userID string, ctx context.Context) *http.Request {
|
|
req := httptest.NewRequest(method, path, nil)
|
|
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", userID)
|
|
|
|
chiCtx := context.WithValue(ctx, chi.RouteCtxKey, rctx)
|
|
return req.WithContext(chiCtx)
|
|
}
|
|
|
|
func createService(ctx context.Context, q db.Querier, name string, price float64) (string, error) {
|
|
var id string
|
|
err := q.QueryRow(ctx, `
|
|
INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING id
|
|
`, name, "Test service", price, 60, true, 16).Scan(&id)
|
|
return id, err
|
|
}
|
|
|
|
func createCompletedBooking(t *testing.T, ctx context.Context, q db.Querier, userID, serviceID, startTime string, price float64) string {
|
|
t.Helper()
|
|
var bookingID string
|
|
err := q.QueryRow(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status)
|
|
VALUES ($1, $2, 'completed')
|
|
RETURNING id
|
|
`, userID, startTime).Scan(&bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create completed booking: %v", err)
|
|
}
|
|
|
|
_, err = q.Exec(ctx, `
|
|
INSERT INTO booking_services (booking_id, service_id, override_price)
|
|
VALUES ($1, $2, $3)
|
|
`, bookingID, serviceID, price)
|
|
if err != nil {
|
|
t.Fatalf("failed to link service to booking: %v", err)
|
|
}
|
|
|
|
return bookingID
|
|
}
|
|
|
|
func createPayment(t *testing.T, ctx context.Context, q db.Querier, bookingID, paymentType string, amount float64) {
|
|
t.Helper()
|
|
_, err := q.Exec(ctx, `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status)
|
|
VALUES ($1, $2, 'in_person_card', $3, 'completed')
|
|
`, bookingID, paymentType, amount)
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFormatPlural(t *testing.T) {
|
|
assert.Equal(t, "1 day", formatPlural(1, "day"))
|
|
assert.Equal(t, "2 days", formatPlural(2, "day"))
|
|
assert.Equal(t, "0 days", formatPlural(0, "day"))
|
|
}
|
|
|
|
|