1c06d1d0d1
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
Stand up the internal chip/benefit mechanic behind the narrow payments interface: context-aware balances and benefits, an atomic chip spend, admin grants as zero-price value sales, the one-directional store-compliance gate (VK/TG same- origin only, web draws direct→vk→tg, VK-iOS frozen, untrusted fail-closed), and per-origin hint and no-ads application with term stacking. Reads are served from an in-process, account-keyed write-through cache (mirroring the suspension gate), so hot paths issue no query to the payments schema. Flip the online-game hint wallet and the ad-banner suppression from the deprecated accounts.hint_balance / paid_account columns to the payments benefit (a hint balance no longer suppresses the banner — only a no-ads benefit does), and fold chip segments and benefits by origin on account merge, inside the merge tx. Add the GET/POST /api/v1/user/wallet edge chain (REST → Connect → FlatBuffers) plus its codec unit test; no wallet UI yet. Bring the frozen owner decisions log into the repo at docs/PAYMENTS_DECISIONS_ru.md (it was untracked under .vscode) and reference it from PLAN.md; record the read-cache design and the present-sources interface in PLAN.md and docs/PAYMENTS.md (+ RU mirror).
280 lines
10 KiB
Go
280 lines
10 KiB
Go
//go:build integration
|
|
|
|
package inttest
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"scrabble/backend/internal/payments"
|
|
)
|
|
|
|
// seedBalance writes a chip balance for an account+source directly (payments has no FK to
|
|
// accounts, so a bare uuid is a valid account id here).
|
|
func seedBalance(t *testing.T, id uuid.UUID, source string, chips int) {
|
|
t.Helper()
|
|
if _, err := testDB.ExecContext(context.Background(),
|
|
`INSERT INTO payments.balances (account_id, source, chips) VALUES ($1,$2,$3)
|
|
ON CONFLICT (account_id, source) DO UPDATE SET chips = EXCLUDED.chips`,
|
|
id, source, chips); err != nil {
|
|
t.Fatalf("seed balance: %v", err)
|
|
}
|
|
}
|
|
|
|
// readBalance reads an account's chip balance for a source (0 when the row is absent).
|
|
func readBalance(t *testing.T, id uuid.UUID, source string) int {
|
|
t.Helper()
|
|
var chips int
|
|
err := testDB.QueryRowContext(context.Background(),
|
|
`SELECT chips FROM payments.balances WHERE account_id=$1 AND source=$2`, id, source).Scan(&chips)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
t.Fatalf("read balance: %v", err)
|
|
}
|
|
return chips
|
|
}
|
|
|
|
// benefitUntil reads an account's no-ads term end for an origin (nil when none).
|
|
func benefitUntil(t *testing.T, id uuid.UUID, origin string) *time.Time {
|
|
t.Helper()
|
|
var until *time.Time
|
|
err := testDB.QueryRowContext(context.Background(),
|
|
`SELECT ads_paid_until FROM payments.benefits WHERE account_id=$1 AND origin=$2`, id, origin).Scan(&until)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
t.Fatalf("read benefit until: %v", err)
|
|
}
|
|
return until
|
|
}
|
|
|
|
// ledgerRows counts an account's ledger rows of a given kind.
|
|
func ledgerRows(t *testing.T, id uuid.UUID, kind string) int {
|
|
t.Helper()
|
|
var n int
|
|
if err := testDB.QueryRowContext(context.Background(),
|
|
`SELECT count(*) FROM payments.ledger WHERE account_id=$1 AND kind=$2`, id, kind).Scan(&n); err != nil {
|
|
t.Fatalf("ledger count: %v", err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
// seedValueProduct creates an active chip-priced value: a product with a CHIP price (method
|
|
// NULL) and the given hint / no-ads-day atoms. It returns the product id.
|
|
func seedValueProduct(t *testing.T, priceChips, hints, noAdsDays int) uuid.UUID {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
prod := uuid.New()
|
|
if _, err := testDB.ExecContext(ctx,
|
|
`INSERT INTO payments.product (product_id, title, active) VALUES ($1,'test value',true)`, prod); err != nil {
|
|
t.Fatalf("seed product: %v", err)
|
|
}
|
|
if _, err := testDB.ExecContext(ctx,
|
|
`INSERT INTO payments.product_price (product_id, method, currency, amount) VALUES ($1, NULL, 'CHIP', $2)`,
|
|
prod, priceChips); err != nil {
|
|
t.Fatalf("seed price: %v", err)
|
|
}
|
|
if hints > 0 {
|
|
if _, err := testDB.ExecContext(ctx,
|
|
`INSERT INTO payments.product_item (product_id, atom_type, quantity) VALUES ($1,'hints',$2)`, prod, hints); err != nil {
|
|
t.Fatalf("seed hints item: %v", err)
|
|
}
|
|
}
|
|
if noAdsDays > 0 {
|
|
if _, err := testDB.ExecContext(ctx,
|
|
`INSERT INTO payments.product_item (product_id, atom_type, quantity) VALUES ($1,'noads_days',$2)`, prod, noAdsDays); err != nil {
|
|
t.Fatalf("seed noads item: %v", err)
|
|
}
|
|
}
|
|
return prod
|
|
}
|
|
|
|
// TestPaymentsSpendAppliesAtomically verifies a chip spend writes the ledger row, decrements the
|
|
// balance and applies the benefit together over Postgres.
|
|
func TestPaymentsSpendAppliesAtomically(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc := newPaymentsService()
|
|
acc := uuid.New()
|
|
seedBalance(t, acc, "direct", 100)
|
|
prod := seedValueProduct(t, 60, 3, 30)
|
|
|
|
if err := svc.Spend(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod); err != nil {
|
|
t.Fatalf("spend: %v", err)
|
|
}
|
|
if got := readBalance(t, acc, "direct"); got != 40 {
|
|
t.Errorf("balance after spend = %d, want 40", got)
|
|
}
|
|
if hints, _ := readBenefit(t, acc, "direct"); hints != 3 {
|
|
t.Errorf("hints after spend = %d, want 3", hints)
|
|
}
|
|
if benefitUntil(t, acc, "direct") == nil {
|
|
t.Error("no-ads term not applied by the spend")
|
|
}
|
|
if n := ledgerRows(t, acc, "spend"); n != 1 {
|
|
t.Errorf("spend ledger rows = %d, want 1", n)
|
|
}
|
|
}
|
|
|
|
// TestPaymentsSpendInsufficientNoWrite verifies an under-funded spend is refused and writes
|
|
// nothing (the balance, benefit and ledger are untouched).
|
|
func TestPaymentsSpendInsufficientNoWrite(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc := newPaymentsService()
|
|
acc := uuid.New()
|
|
seedBalance(t, acc, "direct", 10)
|
|
prod := seedValueProduct(t, 60, 3, 0)
|
|
|
|
if err := svc.Spend(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod); !errors.Is(err, payments.ErrInsufficientChips) {
|
|
t.Fatalf("spend = %v, want ErrInsufficientChips", err)
|
|
}
|
|
if got := readBalance(t, acc, "direct"); got != 10 {
|
|
t.Errorf("balance changed to %d, want 10 (no write)", got)
|
|
}
|
|
if h, _ := readBenefit(t, acc, "direct"); h != 0 {
|
|
t.Errorf("benefit applied on a refused spend: hints %d", h)
|
|
}
|
|
if ledgerRows(t, acc, "spend") != 0 {
|
|
t.Error("spend ledger row written on a refused spend")
|
|
}
|
|
}
|
|
|
|
// TestPaymentsSpendGuardRollsBack forces the in-transaction guard to bite: with a stale read
|
|
// cache the draw plan looks affordable, but the guarded decrement matches no row and the whole
|
|
// transaction rolls back — no ledger row, no benefit, balance unchanged.
|
|
func TestPaymentsSpendGuardRollsBack(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc := newPaymentsService()
|
|
acc := uuid.New()
|
|
seedBalance(t, acc, "direct", 100)
|
|
present := []payments.Source{payments.SourceDirect}
|
|
cxt := payments.NewContext("direct", "web")
|
|
if _, err := svc.Wallet(ctx, acc, cxt, present); err != nil { // warm the cache at 100
|
|
t.Fatalf("warm: %v", err)
|
|
}
|
|
// Drain the real balance behind the cache's back (no invalidation), so the plan over-commits.
|
|
if _, err := testDB.ExecContext(ctx, `UPDATE payments.balances SET chips = 10 WHERE account_id=$1 AND source='direct'`, acc); err != nil {
|
|
t.Fatalf("drain: %v", err)
|
|
}
|
|
prod := seedValueProduct(t, 60, 3, 0)
|
|
|
|
if err := svc.Spend(ctx, acc, cxt, present, prod); !errors.Is(err, payments.ErrInsufficientChips) {
|
|
t.Fatalf("spend = %v, want ErrInsufficientChips", err)
|
|
}
|
|
if got := readBalance(t, acc, "direct"); got != 10 {
|
|
t.Errorf("balance = %d, want 10 (rolled back)", got)
|
|
}
|
|
if ledgerRows(t, acc, "spend") != 0 {
|
|
t.Error("spend ledger row written despite the rollback")
|
|
}
|
|
if h, _ := readBenefit(t, acc, "direct"); h != 0 {
|
|
t.Error("benefit applied despite the rollback")
|
|
}
|
|
}
|
|
|
|
// TestPaymentsGrantCreditsValuesNotChips verifies admin_grant applies a benefit at price 0
|
|
// without ever crediting chips (D16).
|
|
func TestPaymentsGrantCreditsValuesNotChips(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc := newPaymentsService()
|
|
acc := uuid.New()
|
|
|
|
if err := svc.Grant(ctx, acc, payments.SourceDirect, 5, 0, true); err != nil {
|
|
t.Fatalf("grant: %v", err)
|
|
}
|
|
if hints, forever := readBenefit(t, acc, "direct"); hints != 5 || !forever {
|
|
t.Errorf("granted benefit = hints %d forever %v, want 5/true", hints, forever)
|
|
}
|
|
if readBalance(t, acc, "direct") != 0 {
|
|
t.Error("a grant must not credit chips")
|
|
}
|
|
if ledgerRows(t, acc, "admin_grant") != 1 {
|
|
t.Error("admin_grant ledger row missing")
|
|
}
|
|
if ledgerRows(t, acc, "spend") != 0 || ledgerRows(t, acc, "fund") != 0 {
|
|
t.Error("a grant wrote a non-grant ledger row")
|
|
}
|
|
}
|
|
|
|
// TestPaymentsSpendHintByContext verifies a hint is drawn from an applicable origin, and that a
|
|
// direct-origin hint is not usable inside a VK context (the compliance wall for hints).
|
|
func TestPaymentsSpendHintByContext(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc := newPaymentsService()
|
|
acc := uuid.New()
|
|
seedBenefit(t, acc, "direct", 2, false)
|
|
present := []payments.Source{payments.SourceDirect, payments.SourceVK}
|
|
|
|
if spent, err := svc.SpendHint(ctx, acc, payments.NewContext("direct", "web"), present); err != nil || !spent {
|
|
t.Fatalf("web spend hint = %v (err %v), want true", spent, err)
|
|
}
|
|
if h, _ := readBenefit(t, acc, "direct"); h != 1 {
|
|
t.Errorf("hints after spend = %d, want 1", h)
|
|
}
|
|
// Inside VK the applicable origins are {vk} only, so the direct-origin hint is untouched.
|
|
if spent, _ := svc.SpendHint(ctx, acc, payments.NewContext("vk", "android"), present); spent {
|
|
t.Error("direct-origin hint spent inside VK — compliance wall breached")
|
|
}
|
|
if h, _ := readBenefit(t, acc, "direct"); h != 1 {
|
|
t.Errorf("direct hints changed inside VK = %d, want 1 (untouched)", h)
|
|
}
|
|
}
|
|
|
|
// TestPaymentsComplianceGateOverPostgres is the named compliance regression at the integration
|
|
// layer: a direct-origin no-ads benefit applies on the web but never inside a store wrapper.
|
|
func TestPaymentsComplianceGateOverPostgres(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc := newPaymentsService()
|
|
acc := uuid.New()
|
|
if err := svc.Grant(ctx, acc, payments.SourceDirect, 0, 30, false); err != nil {
|
|
t.Fatalf("grant: %v", err)
|
|
}
|
|
present := []payments.Source{payments.SourceDirect, payments.SourceVK}
|
|
|
|
if adFree, _ := svc.AdFree(ctx, acc, payments.NewContext("direct", "web"), present); !adFree {
|
|
t.Error("direct no-ads should apply on web")
|
|
}
|
|
if adFree, _ := svc.AdFree(ctx, acc, payments.NewContext("vk", "android"), present); adFree {
|
|
t.Error("direct no-ads must NOT apply inside VK (compliance wall)")
|
|
}
|
|
if adFree, _ := svc.AdFree(ctx, acc, payments.NewContext("telegram", "web"), present); adFree {
|
|
t.Error("direct no-ads must NOT apply inside Telegram (compliance wall)")
|
|
}
|
|
}
|
|
|
|
// TestPaymentsMergeFoldsSegmentsAndBenefits verifies the merge folds chip segments (sum) and
|
|
// benefits (hints sum, forever OR) by origin, and clears the secondary's rows, over Postgres.
|
|
func TestPaymentsMergeFoldsSegmentsAndBenefits(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc := newPaymentsService()
|
|
primary, secondary := uuid.New(), uuid.New()
|
|
seedBalance(t, primary, "vk", 10)
|
|
seedBalance(t, secondary, "vk", 25)
|
|
seedBenefit(t, primary, "vk", 1, false)
|
|
seedBenefit(t, secondary, "vk", 4, true)
|
|
|
|
tx, err := testDB.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
t.Fatalf("begin: %v", err)
|
|
}
|
|
if err := svc.MergeTx(ctx, tx, primary, secondary); err != nil {
|
|
_ = tx.Rollback()
|
|
t.Fatalf("merge: %v", err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
t.Fatalf("commit: %v", err)
|
|
}
|
|
svc.Invalidate(primary, secondary)
|
|
|
|
if got := readBalance(t, primary, "vk"); got != 35 {
|
|
t.Errorf("merged chips = %d, want 35", got)
|
|
}
|
|
if h, forever := readBenefit(t, primary, "vk"); h != 5 || !forever {
|
|
t.Errorf("merged benefit = hints %d forever %v, want 5/true", h, forever)
|
|
}
|
|
if readBalance(t, secondary, "vk") != 0 {
|
|
t.Error("secondary balance not cleared after merge")
|
|
}
|
|
}
|