2c2316fb5e
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
Sales (chip packs) first, ascending by rouble price; then the chip-exchange values, grouped and price-sorted the same way projectOfferPricing lists them, so the /catalog console mirrors what a buyer sees. Internal cosmetics only — no product behaviour change. The value-group order moves to a shared helper (valueGroup) so the offer and the admin list cannot drift.
181 lines
6.2 KiB
Go
181 lines
6.2 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))
|
|
}
|
|
}
|
|
|
|
// TestSortAdminCatalog checks the admin list is ordered like the public offer: chip packs first,
|
|
// ascending by rouble price; then values, grouped (hints only -> no-ads only -> no-ads + hints) and
|
|
// ascending by chip price within a group. Stable and applied to active + archived alike.
|
|
func TestSortAdminCatalog(t *testing.T) {
|
|
pack := func(title string, rub int64) AdminProduct {
|
|
return AdminProduct{
|
|
Title: title,
|
|
Atoms: []AtomLine{{Atom: atomChips, Quantity: 1}},
|
|
Prices: []PriceLine{{Method: string(SourceDirect), Currency: CurrencyRUB, Amount: rub}},
|
|
}
|
|
}
|
|
value := func(title string, chips int64, atoms ...string) AdminProduct {
|
|
p := AdminProduct{Title: title, Prices: []PriceLine{{Currency: CurrencyChip, Amount: chips}}}
|
|
for _, a := range atoms {
|
|
p.Atoms = append(p.Atoms, AtomLine{Atom: a, Quantity: 1})
|
|
}
|
|
return p
|
|
}
|
|
products := []AdminProduct{
|
|
pack("packDear", 30000),
|
|
value("bundle", 500, "hints", "noads_days"),
|
|
pack("packCheap", 10000),
|
|
value("adsOnly", 150, "noads_days"),
|
|
value("hintsBig", 200, "hints"),
|
|
value("hintsSmall", 50, "hints"),
|
|
}
|
|
SortAdminCatalog(products)
|
|
want := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"}
|
|
for i, p := range products {
|
|
if p.Title != want[i] {
|
|
t.Fatalf("order[%d] = %q, want %q (full: %v)", i, p.Title, want[i], titles(products))
|
|
}
|
|
}
|
|
}
|
|
|
|
// titles extracts the product titles for a failure message.
|
|
func titles(products []AdminProduct) []string {
|
|
out := make([]string, len(products))
|
|
for i, p := range products {
|
|
out[i] = p.Title
|
|
}
|
|
return out
|
|
}
|