395a307eca
CI / changes (pull_request) Successful in 11s
CI / unit (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 29s
CI / ui (pull_request) Successful in 1m27s
CI / conformance (pull_request) Successful in 19s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m52s
Two holes on the refund path, both found by asking what happens when a refund does not come from our own `/_gm` button. The merchant cabinet is a second entry point. An operator can refund there, and such a refund never passes through our API — so the money went back while the chips stayed credited, silently. Handle `refund.succeeded`: the refund is re-read from the API (the notification body is no more evidence here than it is for a payment), bound back to its order through the payment id recorded when the payment was minted, and reversed through the same engine. It is idempotent on (provider, refund id), so the event for a refund the console already recorded reverses nothing twice. The reversal engine is full-refund-only by design — it revokes exactly what the pack funded and rejects any other amount — so a partial refund is recorded as nothing at all and logged loudly for an operator. There is no non-arbitrary way to decide how many chips a part-refund costs, and guessing would be worse than asking a human. Second hole: a refund can still be canceled while pending, and the ledger is append-only. Recording on any non-empty refund id therefore risked revoking a customer's chips for money that stayed with us, with no way to take the row back. The console now records only a `succeeded` refund and tells the operator to press again otherwise — the idempotency key returns the same refund rather than paying twice. Tests: unit (GetRefund, the refund notification envelope, a non-final status surfaced to the caller); integration (a cabinet refund is reversed once and a redelivery is a no-op, the event after a console refund changes nothing, a partial refund records nothing, an unconfirmed refund reverses nothing, a pending refund records nothing until it settles and then does). The suite shares one database and the ledger dedupes refunds globally, so the fake provider now mints a refund id per payment — a constant id made one test's refund look like another's duplicate. Decisions D50 (amended) and D52; the notification subscription list in the deploy docs gains refund.succeeded.
261 lines
9.8 KiB
Go
261 lines
9.8 KiB
Go
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)
|
|
}
|
|
}
|
|
|
|
func TestGetRefundReadsTheObject(t *testing.T) {
|
|
cfg, got := fakeAPI(t, http.StatusOK, `{
|
|
"id":"refund-1","status":"succeeded","payment_id":"pay-1",
|
|
"amount":{"value":"149.00","currency":"RUB"}}`)
|
|
|
|
r, err := cfg.GetRefund(context.Background(), "refund-1")
|
|
if err != nil {
|
|
t.Fatalf("get refund: %v", err)
|
|
}
|
|
if got.method != http.MethodGet || got.path != "/refunds/refund-1" {
|
|
t.Errorf("request = %s %s, want GET /refunds/refund-1", got.method, got.path)
|
|
}
|
|
if r.Status != StatusSucceeded || r.PaymentID != "pay-1" {
|
|
t.Errorf("refund = %+v, want a succeeded refund of pay-1", r)
|
|
}
|
|
}
|
|
|
|
func TestCreateRefundReportsANonFinalStatus(t *testing.T) {
|
|
// A refund can be accepted and still be canceled later, so the caller must be able to see that it
|
|
// is not settled rather than treat any 200 as done.
|
|
cfg, _ := fakeAPI(t, http.StatusOK, `{
|
|
"id":"refund-1","status":"pending","payment_id":"pay-1",
|
|
"amount":{"value":"149.00","currency":"RUB"}}`)
|
|
|
|
r, err := cfg.CreateRefund(context.Background(), RefundRequest{
|
|
PaymentID: "pay-1", Amount: Amount{Value: "149.00", Currency: "RUB"},
|
|
}, "0197-order")
|
|
if err != nil {
|
|
t.Fatalf("create refund: %v", err)
|
|
}
|
|
if r.Status != StatusPending {
|
|
t.Errorf("status = %q, want pending surfaced to the caller", r.Status)
|
|
}
|
|
}
|