feat(backend): update Square integration, validators, and image validation
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -166,10 +166,10 @@ func TestValidateImageBytes_Empty(t *testing.T) {
|
|||||||
// TestValidateImageBytes_AllSupportedFormats verifies every supported format returns a non-empty extension.
|
// TestValidateImageBytes_AllSupportedFormats verifies every supported format returns a non-empty extension.
|
||||||
func TestValidateImageBytes_AllSupportedFormats(t *testing.T) {
|
func TestValidateImageBytes_AllSupportedFormats(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
data []byte
|
data []byte
|
||||||
wantExt string
|
wantExt string
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{"JPEG", []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01}, ".jpg", false},
|
{"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},
|
{"PNG", []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0, 0, 0, 0}, ".png", false},
|
||||||
|
|||||||
@@ -50,4 +50,4 @@ func (p *ProdClient) GetCardsOnFile(ctx context.Context, userID string) ([]CardO
|
|||||||
|
|
||||||
func (p *ProdClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
|
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")
|
return errors.New("square payments not yet configured — set SQUARE_ACCESS_TOKEN and SQUARE_LOCATION_ID in .env")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,13 +14,21 @@ import (
|
|||||||
|
|
||||||
var Client SquareClient
|
var Client SquareClient
|
||||||
|
|
||||||
|
var isTesting = os.Getenv("GO_TESTING") == "1"
|
||||||
|
|
||||||
|
func mockSleep(d time.Duration) {
|
||||||
|
if !isTesting {
|
||||||
|
time.Sleep(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type MockClient struct {
|
type MockClient struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
cards map[string]map[string]*CardOnFile
|
cards map[string]map[string]*CardOnFile
|
||||||
checkouts map[string]*CheckoutResult
|
checkouts map[string]*CheckoutResult
|
||||||
payments map[string]*PaymentResult
|
payments map[string]*PaymentResult
|
||||||
refunds map[string]*RefundResult
|
refunds map[string]*RefundResult
|
||||||
completed map[string]*PaymentResult
|
completed map[string]*PaymentResult
|
||||||
}
|
}
|
||||||
|
|
||||||
type devProdClient struct{}
|
type devProdClient struct{}
|
||||||
@@ -72,7 +80,7 @@ func NewDevClient() SquareClient {
|
|||||||
|
|
||||||
func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
|
func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
|
||||||
log.Printf("[SQUARE-MOCK] CreatePayment: amount=%d, reference=%s", req.Amount, req.ReferenceID)
|
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()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
@@ -110,7 +118,7 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq)
|
|||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(3 * time.Second)
|
mockSleep(3 * time.Second)
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
@@ -122,7 +130,7 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq)
|
|||||||
tipAmount = 500
|
tipAmount = 500
|
||||||
amount += tipAmount
|
amount += tipAmount
|
||||||
}
|
}
|
||||||
fees := amount*175/10000 // in-person rate: 1.75%
|
fees := amount * 175 / 10000 // in-person rate: 1.75%
|
||||||
|
|
||||||
paymentResult := &PaymentResult{
|
paymentResult := &PaymentResult{
|
||||||
ID: paymentID,
|
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) {
|
func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
|
||||||
log.Printf("[SQUARE-MOCK] RefundPayment: payment=%s, amount=%d", req.PaymentID, req.Amount)
|
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()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
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)
|
return fmt.Errorf("card not found: %s", cardID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -350,4 +350,4 @@ func TestDevClient_ConcurrentPayments(t *testing.T) {
|
|||||||
if resultCount != 10 {
|
if resultCount != 10 {
|
||||||
t.Errorf("expected 10 results, got %d", resultCount)
|
t.Errorf("expected 10 results, got %d", resultCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,15 +27,15 @@ type RefundPaymentReq struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PaymentResult struct {
|
type PaymentResult struct {
|
||||||
ID string
|
ID string
|
||||||
Status string // "COMPLETED", "FAILED", "PENDING"
|
Status string // "COMPLETED", "FAILED", "PENDING"
|
||||||
Amount int64
|
Amount int64
|
||||||
CardBrand string
|
CardBrand string
|
||||||
CardLast4 string
|
CardLast4 string
|
||||||
TipAmount int64
|
TipAmount int64
|
||||||
ReceiptURL string
|
ReceiptURL string
|
||||||
SquarePayID string // Square's payment ID
|
SquarePayID string // Square's payment ID
|
||||||
Fees int64 // processing fee in pence
|
Fees int64 // processing fee in pence
|
||||||
}
|
}
|
||||||
|
|
||||||
type CheckoutResult struct {
|
type CheckoutResult struct {
|
||||||
@@ -44,14 +44,14 @@ type CheckoutResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CardOnFile struct {
|
type CardOnFile struct {
|
||||||
ID string
|
ID string
|
||||||
CardID string // Square's card-on-file token
|
CardID string // Square's card-on-file token
|
||||||
Brand string
|
Brand string
|
||||||
Last4 string
|
Last4 string
|
||||||
ExpMonth int
|
ExpMonth int
|
||||||
ExpYear int
|
ExpYear int
|
||||||
Fingerprint string
|
Fingerprint string
|
||||||
IsDefault bool
|
IsDefault bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type RefundResult struct {
|
type RefundResult struct {
|
||||||
@@ -69,4 +69,4 @@ type SquareClient interface {
|
|||||||
CreateCardOnFileRaw(ctx context.Context, userID, cardNumber string, expMonth, expYear int, cvc string) (*CardOnFile, error)
|
CreateCardOnFileRaw(ctx context.Context, userID, cardNumber string, expMonth, expYear int, cvc string) (*CardOnFile, error)
|
||||||
GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error)
|
GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error)
|
||||||
DeleteCardOnFile(ctx context.Context, cardID string) error
|
DeleteCardOnFile(ctx context.Context, cardID string) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package validators
|
package validators
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
"reflect"
|
"reflect"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Validate *validator.Validate
|
var Validate *validator.Validate
|
||||||
@@ -31,3 +33,16 @@ func IsValidID(id string) bool {
|
|||||||
}
|
}
|
||||||
return validIDRegex.MatchString(id)
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user