From 2dbb1486b040457be5222441b6d4d828dd571a1d Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Thu, 18 Jun 2026 16:26:47 +0100 Subject: [PATCH] feat(backend): update Square integration, validators, and image validation Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/internal/images/validate_test.go | 8 ++--- backend/internal/square/square.go | 2 +- backend/internal/square/square_dev.go | 30 ++++++++++++------- backend/internal/square/square_dev_test.go | 2 +- backend/internal/square/types.go | 34 +++++++++++----------- backend/internal/validators/email_test.go | 34 ++++++++++++++++++++++ backend/internal/validators/validators.go | 15 ++++++++++ 7 files changed, 91 insertions(+), 34 deletions(-) diff --git a/backend/internal/images/validate_test.go b/backend/internal/images/validate_test.go index 323b579..2ed4d6d 100644 --- a/backend/internal/images/validate_test.go +++ b/backend/internal/images/validate_test.go @@ -166,10 +166,10 @@ func TestValidateImageBytes_Empty(t *testing.T) { // TestValidateImageBytes_AllSupportedFormats verifies every supported format returns a non-empty extension. func TestValidateImageBytes_AllSupportedFormats(t *testing.T) { tests := []struct { - name string - data []byte - wantExt string - wantErr bool + name string + data []byte + wantExt string + wantErr bool }{ {"JPEG", []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01}, ".jpg", false}, {"PNG", []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0, 0, 0, 0}, ".png", false}, diff --git a/backend/internal/square/square.go b/backend/internal/square/square.go index 4047447..e123d29 100644 --- a/backend/internal/square/square.go +++ b/backend/internal/square/square.go @@ -50,4 +50,4 @@ func (p *ProdClient) GetCardsOnFile(ctx context.Context, userID string) ([]CardO func (p *ProdClient) DeleteCardOnFile(ctx context.Context, cardID string) error { return errors.New("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env") -} \ No newline at end of file +} diff --git a/backend/internal/square/square_dev.go b/backend/internal/square/square_dev.go index 53825fa..c948443 100644 --- a/backend/internal/square/square_dev.go +++ b/backend/internal/square/square_dev.go @@ -14,13 +14,21 @@ import ( var Client SquareClient +var isTesting = os.Getenv("GO_TESTING") == "1" + +func mockSleep(d time.Duration) { + if !isTesting { + time.Sleep(d) + } +} + type MockClient struct { - mu sync.RWMutex - cards map[string]map[string]*CardOnFile - checkouts map[string]*CheckoutResult - payments map[string]*PaymentResult - refunds map[string]*RefundResult - completed map[string]*PaymentResult + mu sync.RWMutex + cards map[string]map[string]*CardOnFile + checkouts map[string]*CheckoutResult + payments map[string]*PaymentResult + refunds map[string]*RefundResult + completed map[string]*PaymentResult } type devProdClient struct{} @@ -72,7 +80,7 @@ func NewDevClient() SquareClient { func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) { log.Printf("[SQUARE-MOCK] CreatePayment: amount=%d, reference=%s", req.Amount, req.ReferenceID) - time.Sleep(1 * time.Second) + mockSleep(1 * time.Second) m.mu.Lock() defer m.mu.Unlock() @@ -110,7 +118,7 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq) m.mu.Unlock() go func() { - time.Sleep(3 * time.Second) + mockSleep(3 * time.Second) m.mu.Lock() defer m.mu.Unlock() @@ -122,7 +130,7 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq) tipAmount = 500 amount += tipAmount } - fees := amount*175/10000 // in-person rate: 1.75% + fees := amount * 175 / 10000 // in-person rate: 1.75% paymentResult := &PaymentResult{ ID: paymentID, @@ -171,7 +179,7 @@ func (m *MockClient) GetCheckout(ctx context.Context, checkoutID string) (*Payme func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) { log.Printf("[SQUARE-MOCK] RefundPayment: payment=%s, amount=%d", req.PaymentID, req.Amount) - time.Sleep(1 * time.Second) + mockSleep(1 * time.Second) m.mu.Lock() defer m.mu.Unlock() @@ -285,4 +293,4 @@ func (m *MockClient) DeleteCardOnFile(ctx context.Context, cardID string) error } } return fmt.Errorf("card not found: %s", cardID) -} \ No newline at end of file +} diff --git a/backend/internal/square/square_dev_test.go b/backend/internal/square/square_dev_test.go index 86760b2..78666c3 100644 --- a/backend/internal/square/square_dev_test.go +++ b/backend/internal/square/square_dev_test.go @@ -350,4 +350,4 @@ func TestDevClient_ConcurrentPayments(t *testing.T) { if resultCount != 10 { t.Errorf("expected 10 results, got %d", resultCount) } -} \ No newline at end of file +} diff --git a/backend/internal/square/types.go b/backend/internal/square/types.go index 61afad1..f87bdfc 100644 --- a/backend/internal/square/types.go +++ b/backend/internal/square/types.go @@ -27,15 +27,15 @@ type RefundPaymentReq struct { } type PaymentResult struct { - ID string - Status string // "COMPLETED", "FAILED", "PENDING" - Amount int64 - CardBrand string - CardLast4 string - TipAmount int64 - ReceiptURL string - SquarePayID string // Square's payment ID - Fees int64 // processing fee in pence + ID string + Status string // "COMPLETED", "FAILED", "PENDING" + Amount int64 + CardBrand string + CardLast4 string + TipAmount int64 + ReceiptURL string + SquarePayID string // Square's payment ID + Fees int64 // processing fee in pence } type CheckoutResult struct { @@ -44,14 +44,14 @@ type CheckoutResult struct { } type CardOnFile struct { - ID string - CardID string // Square's card-on-file token - Brand string - Last4 string - ExpMonth int - ExpYear int + ID string + CardID string // Square's card-on-file token + Brand string + Last4 string + ExpMonth int + ExpYear int Fingerprint string - IsDefault bool + IsDefault bool } type RefundResult struct { @@ -69,4 +69,4 @@ type SquareClient interface { CreateCardOnFileRaw(ctx context.Context, userID, cardNumber string, expMonth, expYear int, cvc string) (*CardOnFile, error) GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error) DeleteCardOnFile(ctx context.Context, cardID string) error -} \ No newline at end of file +} diff --git a/backend/internal/validators/email_test.go b/backend/internal/validators/email_test.go index e256b8b..d21b041 100644 --- a/backend/internal/validators/email_test.go +++ b/backend/internal/validators/email_test.go @@ -155,3 +155,37 @@ func TestValidateEmail_ValidMailsAreSafe(t *testing.T) { } } } + +func TestParseCursor_Valid(t *testing.T) { + tm, id, err := ParseCursor("2026-06-15T10:30:00Z|abc123def456") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tm.Year() != 2026 || tm.Month() != 6 || tm.Day() != 15 { + t.Errorf("unexpected time: %v", tm) + } + if id != "abc123def456" { + t.Errorf("expected id 'abc123def456', got %q", id) + } +} + +func TestParseCursor_InvalidFormat(t *testing.T) { + _, _, err := ParseCursor("not-a-valid-cursor") + if err == nil { + t.Fatal("expected error for invalid cursor format, got nil") + } +} + +func TestParseCursor_InvalidTimestamp(t *testing.T) { + _, _, err := ParseCursor("not-a-time|abc123def456") + if err == nil { + t.Fatal("expected error for invalid timestamp, got nil") + } +} + +func TestParseCursor_EmptyCursor(t *testing.T) { + _, _, err := ParseCursor("") + if err == nil { + t.Fatal("expected error for empty cursor, got nil") + } +} diff --git a/backend/internal/validators/validators.go b/backend/internal/validators/validators.go index 2f575a6..c54bc19 100644 --- a/backend/internal/validators/validators.go +++ b/backend/internal/validators/validators.go @@ -1,10 +1,12 @@ package validators import ( + "fmt" "github.com/go-playground/validator/v10" "reflect" "regexp" "strings" + "time" ) var Validate *validator.Validate @@ -31,3 +33,16 @@ func IsValidID(id string) bool { } return validIDRegex.MatchString(id) } + +// ParseCursor splits a "createdAt|id" cursor string into its components. +func ParseCursor(cursor string) (time.Time, string, error) { + parts := strings.SplitN(cursor, "|", 2) + if len(parts) != 2 { + return time.Time{}, "", fmt.Errorf("invalid cursor format") + } + t, err := time.Parse(time.RFC3339, parts[0]) + if err != nil { + return time.Time{}, "", fmt.Errorf("invalid cursor created_at: %w", err) + } + return t, parts[1], nil +}