//go:build integration package inttest import ( "context" "net/http" "strings" "testing" "scrabble/backend/internal/payments" ) // TestConsoleRefundAndExport drives the manual refund and the ledger CSV export: a funded order is // refunded in full (chips revoked, a refund row), the refund is idempotent, and the export carries // the fund + refund rows; the refund POST is CSRF-guarded. func TestConsoleRefundAndExport(t *testing.T) { ctx := context.Background() srv, _, pay := bannerServer(t) h := srv.Handler() id := provisionAccount(t) const origin = "http://admin.test" base := "http://admin.test/_gm/users/" + id.String() // Fund a pack: 100 chips + a fund ledger row. prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900}) res, err := pay.CreateOrder(ctx, id, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa") if err != nil { t.Fatalf("order: %v", err) } paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB) if _, err := pay.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid); err != nil { t.Fatalf("fund: %v", err) } // CSRF: a refund without the origin header is refused. if code, _ := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+res.OrderID.String(), ""); code != http.StatusForbidden { t.Fatalf("refund without origin = %d, want 403", code) } // Refund the order in full → 100 chips revoked (none spent). if code, body := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+res.OrderID.String(), origin); code != http.StatusOK || !strings.Contains(body, "revoked 100 chips") { t.Fatalf("refund = %d, has 'revoked 100 chips' = %v", code, strings.Contains(body, "revoked 100 chips")) } stmt, err := pay.AccountStatement(ctx, id) if err != nil { t.Fatalf("statement: %v", err) } for _, sg := range stmt.Segments { if sg.Source == payments.SourceDirect && sg.Chips != 0 { t.Errorf("after refund: direct chips = %d, want 0", sg.Chips) } } kinds := map[string]int{} for _, e := range stmt.Ledger { kinds[e.Kind]++ } if kinds["fund"] != 1 || kinds["refund"] != 1 { t.Errorf("ledger kinds = %v, want one fund + one refund", kinds) } // A second refund of the same order is idempotent. if code, body := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+res.OrderID.String(), origin); code != http.StatusOK || !strings.Contains(body, "Already refunded") { t.Fatalf("second refund = %d, has 'Already refunded' = %v", code, strings.Contains(body, "Already refunded")) } // Export the whole ledger as CSV: the header, this account, and its fund + refund rows. code, body := consoleDo(h, http.MethodGet, "http://admin.test/_gm/ledger.csv", "", "") if code != http.StatusOK { t.Fatalf("export = %d, want 200", code) } for _, want := range []string{"created_at,account_id,kind", id.String(), ",fund,", ",refund,"} { if !strings.Contains(body, want) { t.Errorf("ledger CSV missing %q", want) } } }