From a66a5bfa081c8be1675845c3e94429da874e0213 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 9 Jul 2026 17:04:14 +0200 Subject: [PATCH] test(payments): integration coverage for the intake credit path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the order→callback→credit path over Postgres: a funded order credits its segment exactly once; a replayed callback for the same order credits nothing (the ledger idempotency index holds); a mismatched paid amount is refused with no write; an expired pending order is still honoured by a late valid callback. --- .../internal/inttest/payments_intake_test.go | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 backend/internal/inttest/payments_intake_test.go diff --git a/backend/internal/inttest/payments_intake_test.go b/backend/internal/inttest/payments_intake_test.go new file mode 100644 index 0000000..756d73c --- /dev/null +++ b/backend/internal/inttest/payments_intake_test.go @@ -0,0 +1,142 @@ +//go:build integration + +package inttest + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + + "scrabble/backend/internal/payments" +) + +// orderStatus reads an order's status. +func orderStatus(t *testing.T, orderID uuid.UUID) string { + t.Helper() + var status string + if err := testDB.QueryRowContext(context.Background(), + `SELECT status FROM payments.orders WHERE order_id=$1`, orderID).Scan(&status); err != nil { + t.Fatalf("read order status: %v", err) + } + return status +} + +// TestPaymentsOrderFundCreditsOnce verifies the intake path over Postgres: creating an order then +// funding it credits the funded segment exactly once, and a replayed callback (the same order) +// credits nothing more — the ledger idempotency index holds. +func TestPaymentsOrderFundCreditsOnce(t *testing.T) { + ctx := context.Background() + svc := newPaymentsService() + acc := uuid.New() + prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900}) // 149.00 RUB funds 100 chips + + res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa") + if err != nil { + t.Fatalf("create order: %v", err) + } + if res.Amount.Minor() != 14900 || res.Amount.Currency() != payments.CurrencyRUB { + t.Fatalf("order amount = %s, want 149.00 RUB", res.Amount) + } + if orderStatus(t, res.OrderID) != "pending" { + t.Errorf("new order status = %s, want pending", orderStatus(t, res.OrderID)) + } + + paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB) + out, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid) + if err != nil { + t.Fatalf("fund: %v", err) + } + if out.AlreadyCredited || out.Chips != 100 || out.Source != payments.SourceDirect { + t.Fatalf("fund outcome = %+v, want 100 chips to direct, not already-credited", out) + } + if got := readBalance(t, acc, "direct"); got != 100 { + t.Errorf("balance after fund = %d, want 100", got) + } + if ledgerRows(t, acc, "fund") != 1 { + t.Errorf("fund ledger rows = %d, want 1", ledgerRows(t, acc, "fund")) + } + if orderStatus(t, res.OrderID) != "paid" { + t.Errorf("order status after fund = %s, want paid", orderStatus(t, res.OrderID)) + } + + // A replayed callback for the same order is rejected by the unique index: no second credit. + out2, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid) + if err != nil { + t.Fatalf("duplicate fund: %v", err) + } + if !out2.AlreadyCredited { + t.Error("duplicate callback not flagged AlreadyCredited") + } + if got := readBalance(t, acc, "direct"); got != 100 { + t.Errorf("balance after duplicate = %d, want 100 (credited once)", got) + } + if ledgerRows(t, acc, "fund") != 1 { + t.Error("duplicate callback wrote a second fund ledger row") + } +} + +// TestPaymentsFundAmountMismatch verifies a callback whose paid amount does not match the order is +// refused and credits nothing (§9: verify the amount after matching by order id). +func TestPaymentsFundAmountMismatch(t *testing.T) { + ctx := context.Background() + svc := newPaymentsService() + acc := uuid.New() + prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900}) + + res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa") + if err != nil { + t.Fatalf("create order: %v", err) + } + underpaid, _ := payments.MoneyFromMinor(100, payments.CurrencyRUB) + if _, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), underpaid); !errors.Is(err, payments.ErrAmountMismatch) { + t.Fatalf("fund = %v, want ErrAmountMismatch", err) + } + if got := readBalance(t, acc, "direct"); got != 0 { + t.Errorf("balance = %d, want 0 (nothing credited on mismatch)", got) + } + if ledgerRows(t, acc, "fund") != 0 { + t.Error("fund ledger row written on an amount mismatch") + } +} + +// TestPaymentsExpiredOrderStillCredits verifies an expired pending order is still honoured by a +// later valid callback (§9/D23: expiry is cosmetic, the money is real). +func TestPaymentsExpiredOrderStillCredits(t *testing.T) { + ctx := context.Background() + svc := newPaymentsService() + acc := uuid.New() + prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900}) + + res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa") + if err != nil { + t.Fatalf("create order: %v", err) + } + // Age the order well past the configured TTL, then sweep it to expired. + if _, err := testDB.ExecContext(ctx, + `UPDATE payments.orders SET created_at = now() - interval '1 day' WHERE order_id=$1`, res.OrderID); err != nil { + t.Fatalf("age order: %v", err) + } + if n, err := svc.ExpireOrders(ctx); err != nil || n < 1 { + t.Fatalf("expire orders = %d (err %v), want at least 1", n, err) + } + if orderStatus(t, res.OrderID) != "expired" { + t.Fatalf("order status = %s, want expired before the late callback", orderStatus(t, res.OrderID)) + } + + paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB) + out, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid) + if err != nil { + t.Fatalf("fund after expiry: %v", err) + } + if out.AlreadyCredited || out.Chips != 100 { + t.Fatalf("fund outcome = %+v, want a fresh 100-chip credit", out) + } + if got := readBalance(t, acc, "direct"); got != 100 { + t.Errorf("balance = %d, want 100 (expired order honoured)", got) + } + if orderStatus(t, res.OrderID) != "paid" { + t.Errorf("order status = %s, want paid after the honoured callback", orderStatus(t, res.OrderID)) + } +}