package yookassa import ( "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "strings" "testing" ) // capture records what the fake API received, so a test can assert on the request the client built. type capture struct { method string path string authUser string authPass string idempotenceKey string body map[string]any } // fakeAPI serves one canned response and records the request. It returns a Config already pointed at // it, which is how every API-shaped test drives the client without touching the network. func fakeAPI(t *testing.T, status int, response string) (Config, *capture) { t.Helper() got := &capture{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got.method = r.Method got.path = r.URL.Path got.authUser, got.authPass, _ = r.BasicAuth() got.idempotenceKey = r.Header.Get("Idempotence-Key") if raw, _ := io.ReadAll(r.Body); len(raw) > 0 { _ = json.Unmarshal(raw, &got.body) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _, _ = io.WriteString(w, response) })) t.Cleanup(srv.Close) return Config{ShopID: "100500", SecretKey: "secret", BaseURL: srv.URL}, got } func TestCreatePaymentBuildsTheRequest(t *testing.T) { cfg, got := fakeAPI(t, http.StatusOK, `{ "id":"23d93cac-000f-5000-8000-126628f15141","status":"pending","paid":false,"test":false, "amount":{"value":"149.00","currency":"RUB"}, "confirmation":{"type":"redirect","confirmation_url":"https://yoomoney.ru/pay/abc"}, "metadata":{"order_id":"0197-order"}}`) amount := Amount{Value: "149.00", Currency: "RUB"} p, err := cfg.CreatePayment(context.Background(), PaymentRequest{ Amount: amount, Capture: true, Confirmation: Confirmation{Type: ConfirmationRedirect, ReturnURL: "https://example.test/pay/yookassa/return"}, Description: "10 chips", Metadata: map[string]string{MetadataOrderID: "0197-order"}, Receipt: SingleItemReceipt("buyer@example.test", "10 chips", amount, VatCodeNone), }, "0197-order") if err != nil { t.Fatalf("create payment: %v", err) } if got.method != http.MethodPost || got.path != "/payments" { t.Errorf("request = %s %s, want POST /payments", got.method, got.path) } if got.authUser != "100500" || got.authPass != "secret" { t.Errorf("basic auth = %q:%q, want the shop id and secret key", got.authUser, got.authPass) } // The idempotence key is what makes a retried create return the original payment instead of // minting a second one for the same order. if got.idempotenceKey != "0197-order" { t.Errorf("Idempotence-Key = %q, want the order id", got.idempotenceKey) } if got.body["capture"] != true { t.Errorf("capture = %v, want true (single-stage payment)", got.body["capture"]) } conf, _ := got.body["confirmation"].(map[string]any) if conf["type"] != ConfirmationRedirect || conf["return_url"] != "https://example.test/pay/yookassa/return" { t.Errorf("confirmation = %v, want a redirect with the return url", conf) } meta, _ := got.body["metadata"].(map[string]any) if meta[MetadataOrderID] != "0197-order" { t.Errorf("metadata = %v, want the order id threaded through", meta) } receipt, _ := got.body["receipt"].(map[string]any) customer, _ := receipt["customer"].(map[string]any) if customer["email"] != "buyer@example.test" { t.Errorf("receipt customer = %v, want the buyer email", customer) } items, _ := receipt["items"].([]any) if len(items) != 1 { t.Fatalf("receipt items = %d, want 1", len(items)) } item, _ := items[0].(map[string]any) if item["vat_code"] != float64(VatCodeNone) || item["payment_subject"] != PaymentSubjectService || item["payment_mode"] != PaymentModeFullPayment { t.Errorf("receipt item fiscal attributes = %v", item) } if p.Confirmation.ConfirmationURL != "https://yoomoney.ru/pay/abc" { t.Errorf("confirmation url = %q, want the hosted payment page", p.Confirmation.ConfirmationURL) } if p.OrderID() != "0197-order" { t.Errorf("OrderID() = %q, want the order the payment funds", p.OrderID()) } } func TestGetPaymentReadsTheObject(t *testing.T) { cfg, got := fakeAPI(t, http.StatusOK, `{ "id":"pay-1","status":"succeeded","paid":true,"test":true, "amount":{"value":"149.00","currency":"RUB"}, "recipient":{"account_id":"100500","gateway_id":"100700"}, "metadata":{"order_id":"0197-order"}}`) p, err := cfg.GetPayment(context.Background(), "pay-1") if err != nil { t.Fatalf("get payment: %v", err) } if got.method != http.MethodGet || got.path != "/payments/pay-1" { t.Errorf("request = %s %s, want GET /payments/pay-1", got.method, got.path) } // A read is idempotent by nature, so no key is sent. if got.idempotenceKey != "" { t.Errorf("Idempotence-Key = %q, want none on a GET", got.idempotenceKey) } if p.Status != StatusSucceeded || !p.Paid { t.Errorf("payment = %s paid=%v, want succeeded and paid", p.Status, p.Paid) } if !p.Test { t.Error("Test = false, want true — a test-shop payment must be distinguishable from a live one") } if p.Recipient.AccountID != "100500" { t.Errorf("recipient.account_id = %q, want the shop id", p.Recipient.AccountID) } } func TestCreateRefundBuildsTheRequest(t *testing.T) { cfg, got := fakeAPI(t, http.StatusOK, `{ "id":"refund-1","status":"succeeded","payment_id":"pay-1", "amount":{"value":"149.00","currency":"RUB"}}`) amount := Amount{Value: "149.00", Currency: "RUB"} r, err := cfg.CreateRefund(context.Background(), RefundRequest{ PaymentID: "pay-1", Amount: amount, Receipt: SingleItemReceipt("buyer@example.test", "10 chips", amount, VatCodeNone), }, "0197-order") if err != nil { t.Fatalf("create refund: %v", err) } if got.method != http.MethodPost || got.path != "/refunds" { t.Errorf("request = %s %s, want POST /refunds", got.method, got.path) } if got.idempotenceKey != "0197-order" { t.Errorf("Idempotence-Key = %q, want the order id (guards a double refund)", got.idempotenceKey) } if got.body["payment_id"] != "pay-1" { t.Errorf("payment_id = %v, want the refunded payment", got.body["payment_id"]) } if _, ok := got.body["receipt"]; !ok { t.Error("refund carries no receipt — a refund receipt is required under «Чеки от ЮKassa»") } // The refund id is distinct from the payment id: the ledger keys the refund row on it, so the // fund and refund rows can coexist under the same partial-unique index. if r.ID != "refund-1" || r.PaymentID != "pay-1" { t.Errorf("refund = %+v, want id refund-1 for payment pay-1", r) } } func TestAPIErrorClassifiesRetryability(t *testing.T) { cfg, _ := fakeAPI(t, http.StatusBadRequest, `{"type":"error","id":"err-1","code":"invalid_request","description":"Receipt item is invalid","parameter":"receipt.items"}`) _, err := cfg.GetPayment(context.Background(), "pay-1") var apiErr *APIError if !errors.As(err, &apiErr) { t.Fatalf("error = %v, want an *APIError", err) } if apiErr.StatusCode != http.StatusBadRequest { t.Errorf("status = %d, want 400", apiErr.StatusCode) } // The offending parameter is what makes a malformed receipt diagnosable rather than a mystery. if !strings.Contains(apiErr.Error(), "receipt.items") { t.Errorf("error text %q does not name the offending parameter", apiErr.Error()) } if apiErr.Retryable() { t.Error("a 400 reported as retryable; a permanent rejection must not be re-delivered") } for _, status := range []int{http.StatusTooManyRequests, http.StatusInternalServerError} { cfg, _ := fakeAPI(t, status, `{"type":"error","description":"try later"}`) _, err := cfg.GetPayment(context.Background(), "pay-1") var e *APIError if !errors.As(err, &e) || !e.Retryable() { t.Errorf("status %d: error = %v, want a retryable *APIError", status, err) } } } func TestUnconfiguredShopMakesNoCall(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Error("an unconfigured shop reached the API") })) t.Cleanup(srv.Close) for name, cfg := range map[string]Config{ "no credentials": {BaseURL: srv.URL}, "no secret key": {ShopID: "100500", BaseURL: srv.URL}, "no shop id": {SecretKey: "secret", BaseURL: srv.URL}, } { if _, err := cfg.GetPayment(context.Background(), "pay-1"); !errors.Is(err, ErrUnconfigured) { t.Errorf("%s: error = %v, want ErrUnconfigured", name, err) } } } func TestEndpointDefaultsToTheProductionAPI(t *testing.T) { if got := (Config{}).endpoint(); got != DefaultBaseURL { t.Errorf("endpoint = %q, want %q", got, DefaultBaseURL) } if got := (Config{BaseURL: "https://api.test/v3/"}).endpoint(); got != "https://api.test/v3" { t.Errorf("endpoint = %q, want the trailing slash trimmed", got) } }