//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") } }