Files
scrabble-game/backend/internal/payments/wallet_test.go
T
Ilia Denisov 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
feat(payments): chip wallet, store-compliance gate and benefit application
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).
2026-07-08 06:06:40 +02:00

183 lines
6.9 KiB
Go

package payments
import (
"context"
"testing"
"time"
"github.com/google/uuid"
)
// base is a fixed clock instant for the deterministic tests.
var base = time.Date(2026, 7, 8, 12, 0, 0, 0, time.UTC)
// seededService builds a Service whose read cache already holds st for id, so the read methods
// resolve without a database (the store's db is nil). The clock is pinned to base.
func seededService(id uuid.UUID, st walletState) *Service {
store := NewStore(nil)
store.cache.put(id, st)
return &Service{store: store, clock: func() time.Time { return base }}
}
func TestStackNoAds(t *testing.T) {
future := base.Add(48 * time.Hour)
past := base.Add(-time.Hour)
tests := []struct {
name string
current *time.Time
addDays int
want *time.Time
}{
{"fresh term", nil, 3, new(base.Add(72 * time.Hour))},
{"stack onto future", new(future), 2, new(future.Add(48 * time.Hour))},
{"lapsed term restarts from now", new(past), 1, new(base.Add(24 * time.Hour))},
{"zero days unchanged", new(future), 0, new(future)},
{"zero days, nil stays nil", nil, 0, nil},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := stackNoAds(tc.current, tc.addDays, base)
if (got == nil) != (tc.want == nil) || (got != nil && !got.Equal(*tc.want)) {
t.Errorf("stackNoAds = %v, want %v", got, tc.want)
}
})
}
}
func TestCombineNoAds(t *testing.T) {
pFuture := base.Add(24 * time.Hour)
sFuture := base.Add(72 * time.Hour) // 3 days remaining
// primary future + secondary future: primary end + secondary's remaining (72h).
if got := combineNoAds(new(pFuture), new(sFuture), base); !got.Equal(pFuture.Add(72 * time.Hour)) {
t.Errorf("combine both future = %v, want %v", got, pFuture.Add(72*time.Hour))
}
// primary nil + secondary future: now + secondary's remaining.
if got := combineNoAds(nil, new(sFuture), base); !got.Equal(base.Add(72 * time.Hour)) {
t.Errorf("combine nil primary = %v, want %v", got, base.Add(72*time.Hour))
}
// secondary lapsed: primary unchanged.
if got := combineNoAds(new(pFuture), new(base.Add(-time.Hour)), base); !got.Equal(pFuture) {
t.Errorf("combine lapsed secondary = %v, want %v", got, pFuture)
}
// secondary nil: primary unchanged.
if got := combineNoAds(new(pFuture), nil, base); !got.Equal(pFuture) {
t.Errorf("combine nil secondary = %v, want %v", got, pFuture)
}
}
func TestPlanDraws(t *testing.T) {
st := walletState{chips: map[Source]int{SourceDirect: 30, SourceVK: 50, SourceTelegram: 5}}
// price met entirely by the first (direct) segment.
if draws, ok := planDraws(st, []Source{SourceDirect, SourceVK}, 20); !ok || len(draws) != 1 || draws[0] != (sourceAmount{SourceDirect, 20}) {
t.Errorf("single-segment draw = %v ok=%v", draws, ok)
}
// price spills from direct into vk by priority.
draws, ok := planDraws(st, []Source{SourceDirect, SourceVK, SourceTelegram}, 60)
want := []sourceAmount{{SourceDirect, 30}, {SourceVK, 30}}
if !ok || len(draws) != 2 || draws[0] != want[0] || draws[1] != want[1] {
t.Errorf("priority spill draw = %v ok=%v, want %v", draws, ok, want)
}
// insufficient total.
if _, ok := planDraws(st, []Source{SourceDirect, SourceVK, SourceTelegram}, 200); ok {
t.Error("expected insufficient")
}
}
func TestWalletSegments(t *testing.T) {
id := uuid.New()
st := walletState{chips: map[Source]int{SourceDirect: 100, SourceVK: 50}}
svc := seededService(id, st)
present := []Source{SourceDirect, SourceVK}
// Web/native: both attached segments shown, both spendable.
got, err := svc.Wallet(context.Background(), id, NewContext("direct", "web"), present)
if err != nil {
t.Fatal(err)
}
if len(got.Segments) != 2 || !got.Segments[0].Spendable || got.Segments[0].Source != SourceDirect {
t.Errorf("direct wallet segments = %+v", got.Segments)
}
// VK Android: only vk shown, spendable.
got, _ = svc.Wallet(context.Background(), id, NewContext("vk", "android"), present)
if len(got.Segments) != 1 || got.Segments[0].Source != SourceVK || !got.Segments[0].Spendable {
t.Errorf("vk-android wallet = %+v", got.Segments)
}
// VK iOS: only vk shown, frozen (not spendable) but the balance is visible.
got, _ = svc.Wallet(context.Background(), id, NewContext("vk", "ios"), present)
if len(got.Segments) != 1 || got.Segments[0].Chips != 50 || got.Segments[0].Spendable {
t.Errorf("vk-ios wallet = %+v (want vk 50 frozen)", got.Segments)
}
}
func TestAdFreeByContext(t *testing.T) {
id := uuid.New()
future := base.Add(48 * time.Hour)
ctx := context.Background()
// A vk-origin no-ads term applies inside VK and out on web, but a direct-origin term never
// applies inside VK (the compliance wall).
svc := seededService(id, walletState{benefits: map[Source]benefitState{
SourceVK: {adsPaidUntil: new(future)},
SourceDirect: {adsPaidUntil: new(future)},
}})
present := []Source{SourceDirect, SourceVK}
cases := []struct {
name string
cxt Context
want bool
}{
{"vk term inside vk", NewContext("vk", "android"), true},
{"vk term inside vk-ios (applies while frozen)", NewContext("vk", "ios"), true},
{"terms on web", NewContext("direct", "web"), true},
{"untrusted fail-closed", NewContext("", ""), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := svc.AdFree(ctx, id, tc.cxt, present)
if err != nil || got != tc.want {
t.Errorf("AdFree = %v (err %v), want %v", got, err, tc.want)
}
})
}
// Compliance: ONLY a direct-origin term, checked inside VK, must not suppress ads.
svc2 := seededService(id, walletState{benefits: map[Source]benefitState{SourceDirect: {adsPaidUntil: new(future)}}})
if got, _ := svc2.AdFree(ctx, id, NewContext("vk", "android"), present); got {
t.Error("direct-origin no-ads must NOT apply inside VK (compliance wall)")
}
}
func TestHintsAvailableByContext(t *testing.T) {
id := uuid.New()
svc := seededService(id, walletState{benefits: map[Source]benefitState{
SourceDirect: {hints: 3},
SourceVK: {hints: 2},
}})
present := []Source{SourceDirect, SourceVK}
ctx := context.Background()
// Web: both applicable origins summed.
if n, _ := svc.HintsAvailable(ctx, id, NewContext("direct", "web"), present); n != 5 {
t.Errorf("web hints = %d, want 5", n)
}
// VK: only vk-origin hints.
if n, _ := svc.HintsAvailable(ctx, id, NewContext("vk", "android"), present); n != 2 {
t.Errorf("vk hints = %d, want 2", n)
}
// Untrusted: none.
if n, _ := svc.HintsAvailable(ctx, id, NewContext("", ""), present); n != 0 {
t.Errorf("untrusted hints = %d, want 0", n)
}
}
func TestSpendHintUntrustedNoOp(t *testing.T) {
id := uuid.New()
svc := seededService(id, walletState{benefits: map[Source]benefitState{SourceDirect: {hints: 3}}})
// Untrusted context: no applicable origin, so nothing is spent and the DB (nil) is never hit.
spent, err := svc.SpendHint(context.Background(), id, NewContext("", ""), []Source{SourceDirect})
if err != nil || spent {
t.Errorf("untrusted SpendHint = %v (err %v), want false", spent, err)
}
}