Release v1.14.0 — monetization launch (E5-E8) #237
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user