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).
183 lines
6.9 KiB
Go
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)
|
|
}
|
|
}
|