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
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.
137 lines
4.7 KiB
Go
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))
|
|
}
|
|
}
|