Files
scrabble-game/backend/internal/payments/catalog_test.go
T
Ilia Denisov 4aa6174968
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
feat(payments): wallet screen with balances, benefits and storefront
Add the "Кошелёк" section to the settings hub: context-visible chip
balances, active benefits (no-ads term/forever, hints) and a storefront of
chip-priced values and money-priced chip packs. Guests have no wallet; the
Google Play build hides the money purchases behind a RuStore stub; a web
purchase that would draw VK/Telegram chips warns first.

Add the catalog read path the storefront needs — a context-projected
GET /api/v1/user/wallet/catalog (payments service + store, gateway op
wallet.catalog, FBS Catalog/CatalogProduct/CatalogAtom, client decode) —
plus the client leg for the existing wallet.get/buy ops. Value spends reuse
the existing spend path; the chip-pack purchase (money order flow) arrives
with payment intake, so its action is a disabled placeholder for now.

Covered by Go unit (catalog projection) + integration (/wallet/catalog over
Postgres), vitest (formatting, spendable selection, web-spend warning, GP
flag, codec + gateway encode round-trips) and Playwright mock e2e (render,
guest-hidden, GP stub, warning) on Chromium + WebKit.
2026-07-08 12:37:32 +02:00

137 lines
4.7 KiB
Go

package payments
import (
"testing"
"github.com/google/uuid"
)
// catalog fixtures: a chip-priced value (hints), a multi-method chip pack, a VK-only chip pack, a
// value with no CHIP price (misconfigured), and a pack whose only method is telegram.
func fixtureCatalog() []catalogEntry {
value := catalogEntry{
id: uuid.MustParse("00000000-0000-0000-0000-000000000001"),
title: "250 hints",
atoms: []atomQty{{atomType: "hints", quantity: 250}},
prices: []priceRow{
{method: "", currency: CurrencyChip, amount: 500},
},
}
pack := catalogEntry{
id: uuid.MustParse("00000000-0000-0000-0000-000000000002"),
title: "100 chips",
atoms: []atomQty{{atomType: "chips", quantity: 100}},
prices: []priceRow{
{method: "vk", currency: CurrencyVote, amount: 20},
{method: "telegram", currency: CurrencyStar, amount: 25},
{method: "direct", currency: CurrencyRUB, amount: 14900},
},
}
vkOnlyPack := catalogEntry{
id: uuid.MustParse("00000000-0000-0000-0000-000000000003"),
title: "VK-only chips",
atoms: []atomQty{{atomType: "chips", quantity: 50}},
prices: []priceRow{
{method: "vk", currency: CurrencyVote, amount: 10},
},
}
brokenValue := catalogEntry{
id: uuid.MustParse("00000000-0000-0000-0000-000000000004"),
title: "no chip price",
atoms: []atomQty{{atomType: "noads_days", quantity: 30}},
// no CHIP price row — misconfigured, must be omitted
}
return []catalogEntry{value, pack, vkOnlyPack, brokenValue}
}
// byID indexes a projected storefront by product id for assertions.
func byID(v CatalogView) map[string]CatalogProduct {
m := make(map[string]CatalogProduct, len(v.Products))
for _, p := range v.Products {
m[p.ProductID] = p
}
return m
}
func TestProjectCatalog_ContextMatrix(t *testing.T) {
const (
valueID = "00000000-0000-0000-0000-000000000001"
packID = "00000000-0000-0000-0000-000000000002"
vkPackID = "00000000-0000-0000-0000-000000000003"
brokenID = "00000000-0000-0000-0000-000000000004"
)
entries := fixtureCatalog()
tests := []struct {
name string
cxt Context
wantPackAmt int64
wantPackCur Currency
wantVKPack bool // the vk-only pack visible?
}{
{"vk", Context{Kind: SourceVK}, 20, CurrencyVote, true},
{"vk-ios frozen still lists vk price", Context{Kind: SourceVK, Subtype: SubtypeIOS}, 20, CurrencyVote, true},
{"telegram", Context{Kind: SourceTelegram}, 25, CurrencyStar, false},
{"direct", Context{Kind: SourceDirect}, 14900, CurrencyRUB, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := byID(projectCatalog(entries, tc.cxt))
// The value shows everywhere at its uniform chip price, never a money price.
v, ok := got[valueID]
if !ok {
t.Fatalf("value missing from %s storefront", tc.name)
}
if v.Kind != "value" || v.Chips != 500 || v.MoneyCurrency != "" || v.MoneyAmount != 0 {
t.Errorf("value = %+v, want kind=value chips=500 no money", v)
}
if len(v.Atoms) != 1 || v.Atoms[0].AtomType != "hints" || v.Atoms[0].Quantity != 250 {
t.Errorf("value atoms = %+v, want [hints:250]", v.Atoms)
}
// The multi-method pack shows the context method's price, in that currency, no chips.
p, ok := got[packID]
if !ok {
t.Fatalf("pack missing from %s storefront", tc.name)
}
if p.Kind != "pack" || p.Chips != 0 || p.MoneyAmount != tc.wantPackAmt || p.MoneyCurrency != string(tc.wantPackCur) {
t.Errorf("pack = %+v, want kind=pack amount=%d currency=%s", p, tc.wantPackAmt, tc.wantPackCur)
}
// The vk-only pack shows only where a vk price exists.
if _, ok := got[vkPackID]; ok != tc.wantVKPack {
t.Errorf("vk-only pack present=%v, want %v (%s)", ok, tc.wantVKPack, tc.name)
}
// The misconfigured value (no CHIP price) is never shown.
if _, ok := got[brokenID]; ok {
t.Errorf("misconfigured value must be omitted (%s)", tc.name)
}
})
}
}
// An untrusted context (empty Kind) has no payment method, so it shows values only — no packs.
func TestProjectCatalog_UntrustedShowsValuesOnly(t *testing.T) {
got := byID(projectCatalog(fixtureCatalog(), Context{}))
if _, ok := got["00000000-0000-0000-0000-000000000001"]; !ok {
t.Error("untrusted context should still list the chip-priced value")
}
for _, id := range []string{
"00000000-0000-0000-0000-000000000002",
"00000000-0000-0000-0000-000000000003",
} {
if _, ok := got[id]; ok {
t.Errorf("untrusted context must not list pack %s", id)
}
}
}
// An empty catalog projects to an empty storefront (no products seeded yet — the Release-1 state).
func TestProjectCatalog_Empty(t *testing.T) {
if got := projectCatalog(nil, Context{Kind: SourceDirect}); len(got.Products) != 0 {
t.Errorf("empty catalog projected %d products, want 0", len(got.Products))
}
}