Files
bd2/go/internal/server/commerce/service_test.go
T
Flechazo d8771caef2 feat(all): add hunting and events, commerce and rewards, binary calendars, GameData caching and login diagnostics
Implement hunting AP and progress, event tasks, exchanges, games, stories and actions; connect battle and reward handling.

Add CashShop plugin and installation, server purchases and entitlements, recharge rules and plugin version metadata.

Distribute binary calendars independently; cache GameData in memory and fix single-connection query deadlocks.

Improve automatic login and recovery diagnostics; document configuration and release flows.
2026-10-05 11:50:31 +08:00

317 lines
10 KiB
Go

package commerce
import (
"bd2server/internal/server/accountstate"
"bd2server/internal/server/gameconfig"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/readonly"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"bytes"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"testing"
"time"
)
type purchaseEconomy struct {
calls int
costs, rewards []gamedata.Reward
fail bool
}
func (e *purchaseEconomy) Apply(_ string, c, r []gamedata.Reward) ([]byte, error) {
e.calls++
e.costs = c
e.rewards = r
if e.fail {
return nil, fmt.Errorf("insufficient balance")
}
return []byte{8, 1}, nil
}
func serviceFixture(t *testing.T, limit uint64) (*Service, *purchaseEconomy, *stateio.Memory) {
t.Helper()
design := &gamedata.CashCatalog{Products: []gamedata.CashProductDesign{
{Key: gamedata.CashProductKey{GroupID: 1, ProductID: 1}, GoogleSKU: "topup", PriceType: 1, PriceCount: 2200, RandomBoxID: 10, Recharge: true, NominalPaidDiamonds: 100},
{Key: gamedata.CashProductKey{GroupID: 2, ProductID: 1}, GoogleSKU: "goods", PriceType: 1, PriceCount: 22000, RandomBoxID: 20, PurchaseLimitType: limit, PurchaseLimitCount: 1},
{Key: gamedata.CashProductKey{GroupID: 2, ProductID: 1, SaleGroup: 1}, GoogleSKU: "goods-sale", PriceType: 1, PriceCount: 11000, RandomBoxID: 21, PurchaseLimitType: 4, PurchaseLimitCount: 1},
}}
c, err := NewCatalog("test", design, gameconfig.Default().Purchases)
if err != nil {
t.Fatal(err)
}
eco := &purchaseEconomy{}
store := stateio.NewMemory()
s, err := NewService(c, store, eco)
if err != nil {
t.Fatal(err)
}
return s, eco, store
}
func buyRequest(seq, group, id, sale uint64, billing string) []byte {
b := wire.AppendVarint(nil, 1, seq)
b = wire.AppendVarint(b, 3, group)
line := wire.AppendVarint(nil, 1, id)
line = wire.AppendVarint(line, 2, sale)
line = wire.AppendVarint(line, 3, 1)
b = wire.AppendBytes(b, 4, line)
if billing == "" {
billing = fmt.Sprint(seq)
}
if billing == "pay-1" {
billing = "1"
}
typ, amount := uint64(2), uint64(1000)
if sale == 1 {
amount = 500
}
if group == 1 {
typ = 0
amount = 0
}
info := wire.AppendBytes(nil, 1, []byte(billing))
info = wire.AppendBytes(info, 2, []byte(fmt.Sprintf("bd2-local-commerce-v1:%s:%d:%d", billing, typ, amount)))
b = wire.AppendBytes(b, 7, info)
return b
}
func TestPurchaseReplayPersistsAndRejectsCollisions(t *testing.T) {
s, eco, store := serviceFixture(t, 0)
req := buyRequest(1, 2, 1, 0, "pay-1")
code, out, ok, err := s.HandleSession("/CashShopBuy", req, "session")
if err != nil || !ok || code != 61 {
t.Fatal(code, ok, err)
}
if eco.calls != 1 || len(eco.costs) != 1 || eco.costs[0].Type != 2 || eco.costs[0].Count != 1000 || eco.rewards[0].ID != 20 {
t.Fatal(eco)
}
s2, err := NewService(s.catalog, store, eco)
if err != nil {
t.Fatal(err)
}
_, replayed, _, err := s2.HandleSession("/CashShopBuy", req, "session")
if err != nil || !bytes.Equal(out, replayed) || eco.calls != 1 {
t.Fatal(err, eco.calls)
}
changed := buyRequest(1, 2, 1, 1, "")
if _, _, _, err = s2.HandleSession("/CashShopBuy", changed, "session"); err == nil {
t.Fatal("changed sequence accepted")
}
if _, _, _, err = s2.HandleSession("/CashShopBuy", req, "other-session"); err == nil {
t.Fatal("billing identity reused")
}
}
func TestSaleGroupsLimitsAndReset(t *testing.T) {
s, eco, _ := serviceFixture(t, 1)
now := time.Date(2026, 10, 5, 1, 0, 0, 0, time.UTC)
s.SetClock(func() time.Time { return now }, 0)
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(2, 2, 1, 0, ""), "s"); err == nil {
t.Fatal("daily limit ignored")
}
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(3, 2, 1, 1, ""), "s"); err != nil {
t.Fatal("sale group should be independent", err)
}
now = now.Add(24 * time.Hour)
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(4, 2, 1, 0, ""), "s"); err != nil {
t.Fatal(err)
}
if eco.calls != 3 {
t.Fatal(eco.calls)
}
}
func TestFailedEconomyDoesNotConsumeLimitAndFreeRecharge(t *testing.T) {
s, eco, _ := serviceFixture(t, 4)
eco.fail = true
req := buyRequest(1, 2, 1, 0, "")
if _, _, _, err := s.HandleSession("/CashShopBuy", req, "s"); err == nil {
t.Fatal("failure accepted")
}
if s.HasPurchased(gamedata.CashProductKey{GroupID: 2, ProductID: 1}) {
t.Fatal("failed buy persisted")
}
eco.fail = false
if _, _, _, err := s.HandleSession("/CashShopBuy", req, "s"); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(2, 1, 1, 0, ""), "s"); err != nil {
t.Fatal(err)
}
if len(eco.costs) != 0 || eco.rewards[0].ID != 10 {
t.Fatal("free recharge wrong", eco)
}
}
func TestAuthenticatedSessionRequired(t *testing.T) {
s, eco, _ := serviceFixture(t, 0)
if _, _, _, err := s.Handle("/CashShopBuy", buyRequest(1, 2, 1, 0, "")); err == nil || eco.calls != 0 {
t.Fatal("unauthenticated buy accepted")
}
}
func TestPassEntitlementConsumedOncePerPurchase(t *testing.T) {
s, _, _ := serviceFixture(t, 0)
k := gamedata.CashProductKey{GroupID: 2, ProductID: 1}
if s.ConsumeEntitlement(k) {
t.Fatal("unpaid entitlement authorized")
}
for i := uint64(1); i <= 2; i++ {
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(i, 2, 1, 0, ""), "s"); err != nil {
t.Fatal(err)
}
if !s.ConsumeEntitlement(k) || s.ConsumeEntitlement(k) {
t.Fatal("entitlement count not enforced")
}
}
}
func TestRelayRequiresPreviousProduct(t *testing.T) {
s, eco, _ := serviceFixture(t, 0)
if err := s.AttachPackageRules([]gamedata.CashPackageDesign{{GroupID: 2, ID: 1, PackageType: 8, ContentsGroupID: 7, ContentsSortID: 1}, {GroupID: 2, ID: 1, SaleGroup: 1, PackageType: 8, ContentsGroupID: 7, ContentsSortID: 2}}); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 1, ""), "s"); err == nil || eco.calls != 0 {
t.Fatal("relay step skipped")
}
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(2, 2, 1, 0, ""), "s"); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(3, 2, 1, 1, ""), "s"); err != nil {
t.Fatal(err)
}
}
func TestStaleClientQuoteRejectedBeforeEconomy(t *testing.T) {
s, eco, _ := serviceFixture(t, 0)
req := buyRequest(1, 2, 1, 0, "")
req = bytes.ReplaceAll(req, []byte(":2:1000"), []byte(":3:1000"))
if _, _, _, err := s.HandleSession("/CashShopBuy", req, "s"); err == nil || eco.calls != 0 {
t.Fatal("stale currency accepted")
}
req = buyRequest(2, 2, 1, 0, "")
req = bytes.ReplaceAll(req, []byte(":2:1000"), []byte(":2:9999"))
if _, _, _, err := s.HandleSession("/CashShopBuy", req, "s"); err == nil || eco.calls != 0 {
t.Fatal("stale amount accepted")
}
}
func TestShopWindowFiltersAndAuthorizesProducts(t *testing.T) {
s, eco, _ := serviceFixture(t, 0)
now := time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)
s.SetClock(func() time.Time { return now }, 0)
seed := &readonly.Seed{Responses: map[string]readonly.Response{"/CashShopInfo": {PacketCode: 60, Fields: []readonly.Field{{Number: 1, Type: 2, Fields: []readonly.Field{{Number: 1, Type: 0, Varint: 2}, {Number: 2, Type: 0, Varint: 1}, {Number: 4, Type: 0, Varint: uint64(now.Add(-time.Hour).UnixMilli())}, {Number: 5, Type: 0, Varint: uint64(now.Add(time.Hour).UnixMilli())}, {Number: 8, Type: 0, Varint: 123}}}}}}}
if err := s.AttachShopSeed(seed); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err != nil {
t.Fatal(err)
}
now = now.Add(2 * time.Hour)
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(2, 2, 1, 0, ""), "s"); err == nil {
t.Fatal("expired product accepted")
}
code, out, ok, err := s.HandleSession("/CashShopInfo", wire.AppendVarint(nil, 1, 1), "s")
if err != nil || !ok || code != 60 {
t.Fatal(err)
}
products := 0
_ = wire.Walk(out, func(f wire.Field) error {
if f.Number == 1 {
products++
}
return nil
})
if products != 0 || eco.calls != 1 {
t.Fatal(products, eco.calls)
}
}
type walletPurchaseEconomy struct {
wallet *player.Wallet
store stateio.Store
}
func (e walletPurchaseEconomy) Apply(identity string, costs, rewards []gamedata.Reward) ([]byte, error) {
if err := e.wallet.ExchangeOnce(identity, costs, nil); err != nil {
return nil, err
}
raw, err := json.Marshal(rewards)
if err != nil {
return nil, err
}
if err = e.store.Save("commerce_test_rewards", raw); err != nil {
return nil, err
}
return []byte{8, 1}, nil
}
func TestSQLiteAccountTransactionRollsBackCostRewardAndReceipt(t *testing.T) {
s, _, _ := serviceFixture(t, 4)
path := filepath.Join(t.TempDir(), "state.db")
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = repo.Close() })
wallet, err := player.OpenWallet(repo, player.Currency{Jewelry: 1000})
if err != nil {
t.Fatal(err)
}
if err = wallet.EnsurePersisted(); err != nil {
t.Fatal(err)
}
s, err = NewService(s.catalog, repo, walletPurchaseEconomy{wallet, repo})
if err != nil {
t.Fatal(err)
}
s.AttachPurchaseHook(func(string, gamedata.CashProductDesign, uint64) error { return fmt.Errorf("entitlement failure") })
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
if _, _, _, err = s.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err == nil {
t.Fatal("hook failure lost")
}
if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) {
t.Fatal(err)
}
if err = repo.Close(); err != nil {
t.Fatal(err)
}
repo, err = accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
defer repo.Close()
wallet, err = player.OpenWallet(repo, player.Currency{})
if err != nil {
t.Fatal(err)
}
if wallet.Snapshot().Jewelry != 1000 {
t.Fatal("paid diamonds survived rollback")
}
for _, name := range []string{"commerce", "commerce_test_rewards"} {
raw, err := repo.Load(name)
if err != nil || raw != nil {
t.Fatal("purchase state survived rollback", name, err)
}
}
s, err = NewService(s.catalog, repo, walletPurchaseEconomy{wallet, repo})
if err != nil {
t.Fatal(err)
}
op, err = repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
if _, _, _, err = s.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err != nil {
_ = op.Rollback()
t.Fatal(err)
}
if err = op.Commit(); err != nil {
t.Fatal(err)
}
if wallet.Snapshot().Jewelry != 0 || !s.HasPurchased(gamedata.CashProductKey{GroupID: 2, ProductID: 1}) {
t.Fatal("retry did not commit")
}
}