feat(ads): rewarded video (VK) — client-attested credit + daily/hourly caps
CI / changes (pull_request) Successful in 4s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
CI / changes (pull_request) Successful in 4s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
The first ads slice: a voluntary rewarded video credits chips. VK Mini App ads (VKWebAppShowNativeAds) expose only a client-side watch result — no server-to-server verify — so the credit is client-attested, guarded by a server daily + hourly cap (config reward_daily_cap / reward_hourly_cap, default 50 / 10). The caps are both anti-abuse (bounding a forger who skips the ad and calls the endpoint directly) and an economic conversion lever (limiting free chips so a player who wants more buys). D29 amended to VK's reality. Backend: CreditReward (VK-only, order-less, idempotent on a client nonce, floored by the caps; payout from config rewarded_payout_chips, default 0 = off) + the wallet.reward edge op returning the updated wallet (reward_chips gates the "watch for chips" CTA). Additive migration (two config columns). Client: the ads-network abstraction (lib/ads.ts, VK impl) + the VK bridge (vkRewardedReady / vkShowRewarded) + the Wallet CTA + i18n. A contour test stub (VITE_ADS_STUB -> a toast instead of a real ad; prod always real) and a temporary diagnostic that logs the raw VK data, so we confirm on the contour exactly what VK returns (harden to signature-verify if it carries one). Tests: backend integration (credit, nonce idempotency, hourly cap, disabled, non-VK refusal) + codec unit (reward wire). Docs: PAYMENTS(+ru) §10, D29 amend, PLAN E6. Bundle: shared budget 30->31 (reward i18n strings).
This commit is contained in:
@@ -240,6 +240,100 @@ func TestPaymentsTelegramPreCheckoutDeclines(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// setRewardConfig sets the rewarded payout and caps on the shared config row, restoring the defaults
|
||||
// after the test (the row is a singleton shared across the sequential suite).
|
||||
func setRewardConfig(t *testing.T, payout, dailyCap, hourlyCap int) {
|
||||
t.Helper()
|
||||
if _, err := testDB.ExecContext(context.Background(),
|
||||
`UPDATE payments.config SET rewarded_payout_chips=$1, reward_daily_cap=$2, reward_hourly_cap=$3`,
|
||||
payout, dailyCap, hourlyCap); err != nil {
|
||||
t.Fatalf("set reward config: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = testDB.ExecContext(context.Background(),
|
||||
`UPDATE payments.config SET rewarded_payout_chips=0, reward_daily_cap=50, reward_hourly_cap=10`)
|
||||
})
|
||||
}
|
||||
|
||||
// TestPaymentsRewardCredit exercises the rewarded-video credit: a watched view credits the VK segment
|
||||
// the configured payout, a retried view (same nonce) credits once, and the hourly cap blocks the
|
||||
// third distinct view.
|
||||
func TestPaymentsRewardCredit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
setRewardConfig(t, 5, 5, 2) // 5 chips/view, daily 5, hourly 2
|
||||
vk := payments.NewContext("vk", "web")
|
||||
present := []payments.Source{payments.SourceVK}
|
||||
|
||||
out, err := svc.CreditReward(ctx, acc, vk, present, "n1")
|
||||
if err != nil {
|
||||
t.Fatalf("credit: %v", err)
|
||||
}
|
||||
if out.Chips != 5 || out.Capped {
|
||||
t.Fatalf("reward outcome = %+v, want 5 chips, not capped", out)
|
||||
}
|
||||
if got := readBalance(t, acc, "vk"); got != 5 {
|
||||
t.Errorf("vk balance = %d, want 5", got)
|
||||
}
|
||||
|
||||
// A retried view (same nonce) credits nothing more.
|
||||
out2, err := svc.CreditReward(ctx, acc, vk, present, "n1")
|
||||
if err != nil {
|
||||
t.Fatalf("retry: %v", err)
|
||||
}
|
||||
if !out2.AlreadyCredited {
|
||||
t.Error("retried view not flagged AlreadyCredited")
|
||||
}
|
||||
if got := readBalance(t, acc, "vk"); got != 5 {
|
||||
t.Errorf("vk balance after retry = %d, want 5 (credited once)", got)
|
||||
}
|
||||
|
||||
// A second distinct view credits again (2 total).
|
||||
if _, err := svc.CreditReward(ctx, acc, vk, present, "n2"); err != nil {
|
||||
t.Fatalf("credit 2: %v", err)
|
||||
}
|
||||
if got := readBalance(t, acc, "vk"); got != 10 {
|
||||
t.Errorf("vk balance = %d, want 10", got)
|
||||
}
|
||||
|
||||
// The third distinct view hits the hourly cap (2) — capped, no credit.
|
||||
out3, err := svc.CreditReward(ctx, acc, vk, present, "n3")
|
||||
if err != nil {
|
||||
t.Fatalf("credit 3: %v", err)
|
||||
}
|
||||
if !out3.Capped {
|
||||
t.Error("third view not capped (hourly cap 2)")
|
||||
}
|
||||
if got := readBalance(t, acc, "vk"); got != 10 {
|
||||
t.Errorf("vk balance after cap = %d, want 10 (capped view credited nothing)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsRewardDisabledAndContext verifies rewarded is inert when unconfigured (0 payout) and
|
||||
// refused outside a VK context (rewarded is VK-only, D28).
|
||||
func TestPaymentsRewardDisabledAndContext(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
setRewardConfig(t, 0, 50, 10) // payout 0 = disabled
|
||||
|
||||
vk := payments.NewContext("vk", "web")
|
||||
out, err := svc.CreditReward(ctx, acc, vk, []payments.Source{payments.SourceVK}, "d1")
|
||||
if err != nil {
|
||||
t.Fatalf("credit (disabled): %v", err)
|
||||
}
|
||||
if out.Chips != 0 || out.Capped || out.AlreadyCredited {
|
||||
t.Fatalf("disabled reward outcome = %+v, want 0 chips, not capped", out)
|
||||
}
|
||||
|
||||
// A direct (non-VK) context is refused — rewarded is VK-only.
|
||||
direct := payments.NewContext("direct", "web")
|
||||
if _, err := svc.CreditReward(ctx, acc, direct, []payments.Source{payments.SourceDirect}, "d2"); !errors.Is(err, payments.ErrUntrusted) {
|
||||
t.Fatalf("direct-context reward = %v, want ErrUntrusted", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
Reference in New Issue
Block a user