//go:build integration package inttest import ( "context" "fmt" "strings" "testing" "time" "github.com/google/uuid" "scrabble/backend/internal/payments" ) // seedLedgerRow writes one ledger row directly, at a chosen age, so a test can stand up a history // that spans a date range without waiting for one. func seedLedgerRow(t *testing.T, acc uuid.UUID, kind, source, provider string, chips int, snapshot string, age time.Duration) { t.Helper() var providerPaymentID any if provider != "" { providerPaymentID = "pp-" + uuid.NewString() } if _, err := testDB.ExecContext(context.Background(), ` INSERT INTO payments.ledger (ledger_id, account_id, kind, source, origin, chips_delta, provider, provider_payment_id, snapshot, created_at) VALUES ($1,$2,$3,$4,$4,$5,NULLIF($6,''),$7,$8::jsonb, now() - $9::interval)`, uuid.New(), acc, kind, source, chips, provider, providerPaymentID, snapshot, fmt.Sprintf("%d seconds", int(age.Seconds()))); err != nil { t.Fatalf("seed ledger row: %v", err) } } // ledgerReport reads a filtered page through the service, the way the console does. func ledgerReport(t *testing.T, pay *payments.Service, f payments.LedgerFilter, limit, offset int) payments.LedgerReport { t.Helper() rep, err := pay.LedgerReportPage(context.Background(), f, limit, offset) if err != nil { t.Fatalf("ledger report: %v", err) } return rep } // TestLedgerReportFilters covers each filter axis over one seeded history, including the property // that matters most for an operator: the totals describe the whole filtered range, not the page. func TestLedgerReportFilters(t *testing.T) { acc, other := provisionAccount(t), provisionAccount(t) // Two recent purchases on different rails, one older purchase outside a narrow range, a spend // (no rail at all) and a refund. seedLedgerRow(t, acc, "fund", "direct", "yookassa", 100, `{"amount_minor":14900,"currency":"RUB","title":"100 chips"}`, time.Hour) seedLedgerRow(t, acc, "fund", "vk", "vk", 50, `{"amount_minor":50,"currency":"VOTE","title":"50 chips"}`, 2*time.Hour) seedLedgerRow(t, other, "fund", "direct", "yookassa", 30, `{"amount_minor":4900,"currency":"RUB","title":"30 chips"}`, 40*24*time.Hour) seedLedgerRow(t, acc, "spend", "direct", "", -20, `{"title":"hints"}`, 30*time.Minute) seedLedgerRow(t, acc, "refund", "direct", "yookassa", -100, `{"amount_minor":14900,"currency":"RUB"}`, 10*time.Minute) pay := newPaymentsService() // Scoped to this test's own account: the suite shares one database and other tests seed recent // ledger rows, so an unscoped count would be a coin toss. recent := payments.LedgerFilter{From: time.Now().Add(-24 * time.Hour), AccountID: acc} t.Run("date range excludes older rows", func(t *testing.T) { rep := ledgerReport(t, pay, recent, 100, 0) for _, r := range rep.Rows { if r.AccountID == other { t.Error("a row from outside the range was returned") } } }) t.Run("account", func(t *testing.T) { rep := ledgerReport(t, pay, payments.LedgerFilter{AccountID: other}, 100, 0) if rep.Total != 1 { t.Fatalf("rows for the other account = %d, want 1", rep.Total) } rep = ledgerReport(t, pay, payments.LedgerFilter{AccountID: acc}, 100, 0) if rep.Total != 4 { t.Fatalf("rows for the account = %d, want 4", rep.Total) } }) t.Run("kind", func(t *testing.T) { f := recent f.Kind = "spend" rep := ledgerReport(t, pay, f, 100, 0) for _, r := range rep.Rows { if r.Kind != "spend" { t.Errorf("kind filter returned a %q row", r.Kind) } } if rep.Total == 0 { t.Error("kind filter matched nothing") } }) t.Run("wallet", func(t *testing.T) { f := recent f.Wallet = "vk" rep := ledgerReport(t, pay, f, 100, 0) if rep.Total != 1 { t.Fatalf("vk wallet rows = %d, want 1", rep.Total) } }) t.Run("rail excludes spends, which have none", func(t *testing.T) { f := recent f.Provider = "yookassa" rep := ledgerReport(t, pay, f, 100, 0) for _, r := range rep.Rows { if r.Kind == "spend" { t.Error("a chip spend matched a rail filter, but a spend has no rail") } } }) t.Run("totals cover the range, not the page", func(t *testing.T) { f := recent full := ledgerReport(t, pay, f, 100, 0) firstPage := ledgerReport(t, pay, f, 1, 0) if len(firstPage.Rows) != 1 { t.Fatalf("page rows = %d, want 1", len(firstPage.Rows)) } if firstPage.Total != full.Total { t.Errorf("page total = %d, want the full match count %d", firstPage.Total, full.Total) } if firstPage.Totals.ChipsIn != full.Totals.ChipsIn || firstPage.Totals.ChipsOut != full.Totals.ChipsOut { t.Errorf("page totals = %+v, want the whole range's %+v", firstPage.Totals, full.Totals) } // 100 + 50 credited, 20 spent + 100 refunded revoked. if full.Totals.ChipsIn != 150 || full.Totals.ChipsOut != 120 { t.Errorf("chip totals = in %d / out %d, want 150 / 120", full.Totals.ChipsIn, full.Totals.ChipsOut) } // Money is kept per currency; summing roubles and Votes together would be meaningless. var rub, vote bool for _, m := range full.Totals.MoneyIn { switch m.Currency() { case payments.CurrencyRUB: rub = true if m.Minor() != 14900 { t.Errorf("RUB in = %d, want 14900", m.Minor()) } case payments.CurrencyVote: vote = true } } if !rub || !vote { t.Errorf("money in = %v, want both a RUB and a VOTE total", full.Totals.MoneyIn) } if len(full.Totals.MoneyRefunded) != 1 || full.Totals.MoneyRefunded[0].Minor() != 14900 { t.Errorf("money refunded = %v, want one 149.00 RUB total", full.Totals.MoneyRefunded) } }) t.Run("money is recovered from the snapshot", func(t *testing.T) { f := recent f.Kind = "fund" f.Wallet = "direct" rep := ledgerReport(t, pay, f, 100, 0) if len(rep.Rows) != 1 { t.Fatalf("rows = %d, want 1", len(rep.Rows)) } r := rep.Rows[0] if !r.HasMoney() || r.Money.Minor() != 14900 || r.Money.Currency() != payments.CurrencyRUB { t.Errorf("row money = %v, want 149.00 RUB", r.Money) } }) t.Run("a spend carries no money", func(t *testing.T) { f := recent f.Kind = "spend" rep := ledgerReport(t, pay, f, 100, 0) if len(rep.Rows) != 1 { t.Fatalf("rows = %d, want 1", len(rep.Rows)) } if rep.Rows[0].HasMoney() { t.Errorf("a chip spend reported money %v", rep.Rows[0].Money) } }) } // TestLedgerReportPaging checks paging walks the range without repeating or dropping a row. func TestLedgerReportPaging(t *testing.T) { acc := provisionAccount(t) for i := range 5 { seedLedgerRow(t, acc, "fund", "direct", "yookassa", 10, `{"amount_minor":1000,"currency":"RUB"}`, time.Duration(i+1)*time.Minute) } pay := newPaymentsService() f := payments.LedgerFilter{AccountID: acc} seen := map[string]bool{} for offset := 0; offset < 5; offset += 2 { rep := ledgerReport(t, pay, f, 2, offset) if rep.Total != 5 { t.Fatalf("total = %d, want 5 at every offset", rep.Total) } for _, r := range rep.Rows { key := r.ProviderPaymentID if seen[key] { t.Errorf("row %s appeared on two pages", key) } seen[key] = true } } if len(seen) != 5 { t.Errorf("walked %d rows across the pages, want 5", len(seen)) } } // TestLedgerConsolePageAndExport drives the console: the page renders under a filter, the CSV export // honours that same filter, and the user card links to the ledger scoped to the account instead of // listing the rows itself. func TestLedgerConsolePageAndExport(t *testing.T) { srv, _, _ := bannerServer(t) acc, other := provisionAccount(t), provisionAccount(t) seedLedgerRow(t, acc, "fund", "direct", "yookassa", 100, `{"amount_minor":14900,"currency":"RUB","title":"Pack A"}`, time.Hour) seedLedgerRow(t, other, "fund", "vk", "vk", 50, `{"amount_minor":50,"currency":"VOTE","title":"Pack B"}`, time.Hour) rec := consoleGet(t, srv, "/_gm/ledger") if rec.Code != 200 { t.Fatalf("ledger page = %d, want 200", rec.Code) } body := rec.Body.String() for _, want := range []string{"Pack A", "Pack B", "149.00 RUB", "Totals for the filtered range"} { if !strings.Contains(body, want) { t.Errorf("ledger page does not show %q", want) } } // Filtering to one account must drop the other's operation from the page. rec = consoleGet(t, srv, "/_gm/ledger?user="+acc.String()) if body := rec.Body.String(); !strings.Contains(body, "Pack A") || strings.Contains(body, "Pack B") { t.Error("the user filter did not scope the ledger page to one account") } // The pager links must carry the filters, or paging would silently widen the view. if body := rec.Body.String(); strings.Contains(body, "page=") && !strings.Contains(body, "user="+acc.String()+"&page=") { t.Error("a pager link dropped the active filters") } // The export honours the same filter as the screen it is linked from. rec = consoleGet(t, srv, "/_gm/ledger.csv?user="+acc.String()) if rec.Code != 200 { t.Fatalf("ledger export = %d, want 200", rec.Code) } csv := rec.Body.String() if !strings.Contains(csv, "Pack A") || strings.Contains(csv, "Pack B") { t.Error("the CSV export ignored the filter") } if !strings.Contains(csv, "amount_minor,currency") { t.Error("the CSV export has no money columns") } // The user card is a summary now: totals and a link into the ledger, not the rows themselves. rec = consoleGet(t, srv, "/_gm/users/"+acc.String()) card := rec.Body.String() if !strings.Contains(card, "/_gm/ledger?user="+acc.String()) { t.Error("the user card does not link to its ledger slice") } if !strings.Contains(card, "Chips credited") { t.Error("the user card shows no totals summary") } }