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, and spendable — the freeze is purchase-only, so VK-wallet chips still // spend there (only buying more chips for money is blocked). 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 spendable)", 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) } }