mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
saveCard resolves the subject's Square customer under a per-(namespace, subject) lock: the customer a saved card names, else the customer Square holds under reference_id "<namespace>/<subject>" (SearchCustomers, exact match, oldest first), else a new one created under that reference. A declined vault followed by a retry, and submits at once, reuse the first customer instead of making another. CreateCustomer sets reference_id from metadata["reference"] and sends an idempotency key: the sha256 of the request body, cut to 45 characters, so the same inputs always carry the same key and the same body. Square's search is eventually consistent; the key covers a retry seconds after a create, the reference search covers later ones. A failed search is logged and the keyed create still runs. A reference over Square's 100-character limit is fitted to its digest, identically in the create and the search. Co-authored-by: Hanzo Dev <[email protected]>
165 lines
5.6 KiB
Go
165 lines
5.6 KiB
Go
package square
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/square/square-go-sdk/v3/core"
|
|
"github.com/square/square-go-sdk/v3/customers/client"
|
|
"github.com/square/square-go-sdk/v3/option"
|
|
)
|
|
|
|
// fakeSquare answers Square's customer endpoints over HTTP: each path replies
|
|
// with reply[path], and every request body is kept under its path. Both are
|
|
// read and changed under mu, the handler's and the test's goroutines alike.
|
|
type fakeSquare struct {
|
|
mu sync.Mutex
|
|
reply map[string]string
|
|
seen map[string][]map[string]any
|
|
}
|
|
|
|
func (f *fakeSquare) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
var body map[string]any
|
|
err := json.NewDecoder(r.Body).Decode(&body)
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if err != nil {
|
|
body = map[string]any{"undecodable": err.Error()}
|
|
}
|
|
if f.seen == nil {
|
|
f.seen = map[string][]map[string]any{}
|
|
}
|
|
f.seen[r.URL.Path] = append(f.seen[r.URL.Path], body)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(f.reply[r.URL.Path]))
|
|
}
|
|
|
|
func (f *fakeSquare) answer(path, reply string) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.reply == nil {
|
|
f.reply = map[string]string{}
|
|
}
|
|
f.reply[path] = reply
|
|
}
|
|
|
|
func (f *fakeSquare) sent(path string) []map[string]any {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.seen[path]
|
|
}
|
|
|
|
// squareAt is a processor whose customer calls go to f.
|
|
func squareAt(t *testing.T, f *fakeSquare) *SquareProcessor {
|
|
t.Helper()
|
|
srv := httptest.NewServer(f)
|
|
t.Cleanup(srv.Close)
|
|
sp := NewProcessor(Config{AccessToken: "tok", LocationID: "loc", Environment: "sandbox"})
|
|
sp.customersClient = client.NewClient(core.NewRequestOptions(option.WithToken("tok"), option.WithBaseURL(srv.URL)))
|
|
return sp
|
|
}
|
|
|
|
// TestCreateCustomer_CarriesReferenceAndKey: a create names its reference and
|
|
// carries an idempotency key within Square's limit, the same key for the same
|
|
// (reference, email) and another key for another email.
|
|
func TestCreateCustomer_CarriesReferenceAndKey(t *testing.T) {
|
|
f := &fakeSquare{}
|
|
f.answer("/v2/customers", `{"customer":{"id":"cust_1"}}`)
|
|
sp := squareAt(t, f)
|
|
ctx := context.Background()
|
|
meta := func() map[string]interface{} {
|
|
return map[string]interface{}{"reference": "acme/acme/alice", "note": "Hanzo billing customer acme/alice"}
|
|
}
|
|
|
|
for _, email := range []string{"[email protected]", "[email protected]", "[email protected]"} {
|
|
id, err := sp.CreateCustomer(ctx, email, "acme/alice", meta())
|
|
if err != nil || id != "cust_1" {
|
|
t.Fatalf("CreateCustomer(%s) = %q, %v", email, id, err)
|
|
}
|
|
}
|
|
|
|
sent := f.sent("/v2/customers")
|
|
if len(sent) != 3 {
|
|
t.Fatalf("creates sent=%d, want 3", len(sent))
|
|
}
|
|
for i, body := range sent {
|
|
if body["reference_id"] != "acme/acme/alice" {
|
|
t.Fatalf("create %d reference_id=%v, want acme/acme/alice", i, body["reference_id"])
|
|
}
|
|
key, _ := body["idempotency_key"].(string)
|
|
if key == "" || len(key) > keyMax {
|
|
t.Fatalf("create %d idempotency_key=%q, want 1..%d characters", i, key, keyMax)
|
|
}
|
|
}
|
|
if sent[0]["idempotency_key"] != sent[1]["idempotency_key"] {
|
|
t.Fatalf("same reference and email sent keys %v and %v, want one", sent[0]["idempotency_key"], sent[1]["idempotency_key"])
|
|
}
|
|
if sent[0]["idempotency_key"] == sent[2]["idempotency_key"] {
|
|
t.Fatal("another email sent the same key, which Square would refuse with a different body")
|
|
}
|
|
}
|
|
|
|
// TestFindCustomer_SearchesTheReferenceExactly: the search asks for one
|
|
// customer whose reference_id is exactly the reference, oldest first, and
|
|
// answers "" when Square holds none.
|
|
func TestFindCustomer_SearchesTheReferenceExactly(t *testing.T) {
|
|
f := &fakeSquare{}
|
|
f.answer("/v2/customers/search", `{"customers":[{"id":"cust_old"}]}`)
|
|
sp := squareAt(t, f)
|
|
ctx := context.Background()
|
|
|
|
id, err := sp.FindCustomer(ctx, "acme/acme")
|
|
if err != nil || id != "cust_old" {
|
|
t.Fatalf("FindCustomer = %q, %v, want cust_old", id, err)
|
|
}
|
|
body := f.sent("/v2/customers/search")[0]
|
|
if body["limit"] != float64(1) {
|
|
t.Fatalf("limit=%v, want 1", body["limit"])
|
|
}
|
|
query, _ := body["query"].(map[string]any)
|
|
filter, _ := query["filter"].(map[string]any)
|
|
ref, _ := filter["reference_id"].(map[string]any)
|
|
if ref["exact"] != "acme/acme" || ref["fuzzy"] != nil {
|
|
t.Fatalf("reference filter=%v, want exact acme/acme", ref)
|
|
}
|
|
sort, _ := query["sort"].(map[string]any)
|
|
if sort["field"] != "CREATED_AT" {
|
|
t.Fatalf("sort=%v, want CREATED_AT", sort)
|
|
}
|
|
|
|
f.answer("/v2/customers/search", `{}`)
|
|
if id, err := sp.FindCustomer(ctx, "acme/acme"); err != nil || id != "" {
|
|
t.Fatalf("FindCustomer with no match = %q, %v, want \"\"", id, err)
|
|
}
|
|
}
|
|
|
|
// TestCustomerReference_FitsTheLimit: a reference over Square's limit is sent
|
|
// as the same fitted value by the create and the search, so they still meet.
|
|
func TestCustomerReference_FitsTheLimit(t *testing.T) {
|
|
f := &fakeSquare{}
|
|
f.answer("/v2/customers", `{"customer":{"id":"cust_long"}}`)
|
|
f.answer("/v2/customers/search", `{}`)
|
|
sp := squareAt(t, f)
|
|
ctx := context.Background()
|
|
long := "acme/acme/" + strings.Repeat("u", 120)
|
|
|
|
if _, err := sp.CreateCustomer(ctx, "[email protected]", "acme/u", map[string]interface{}{"reference": long}); err != nil {
|
|
t.Fatalf("CreateCustomer: %v", err)
|
|
}
|
|
if _, err := sp.FindCustomer(ctx, long); err != nil {
|
|
t.Fatalf("FindCustomer: %v", err)
|
|
}
|
|
made, _ := f.sent("/v2/customers")[0]["reference_id"].(string)
|
|
query, _ := f.sent("/v2/customers/search")[0]["query"].(map[string]any)
|
|
filter, _ := query["filter"].(map[string]any)
|
|
ref, _ := filter["reference_id"].(map[string]any)
|
|
if made == "" || len(made) > refMax || made != ref["exact"] {
|
|
t.Fatalf("created under %q, searched %v, want one value within %d characters", made, ref["exact"], refMax)
|
|
}
|
|
}
|