Files
bd2/go/internal/server/session/attendance_transaction_test.go
T

306 lines
9.8 KiB
Go

package session
import (
"encoding/base64"
"encoding/json"
"fmt"
"path/filepath"
"reflect"
"testing"
"time"
"bd2server/internal/server/accountstate"
"bd2server/internal/server/cryptox"
"bd2server/internal/server/events"
"bd2server/internal/server/eventtasks"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/mail"
"bd2server/internal/server/player"
"bd2server/internal/server/protocol"
"bd2server/internal/server/wire"
)
// This adapter writes the real normalized wallet and inventory grant ledgers.
type attendanceTransactionEconomy struct {
wallet *player.Wallet
inventory *player.Inventory
calls int
}
func (e *attendanceTransactionEconomy) Apply(identity string, costs, rewards []gamedata.Reward) ([]byte, error) {
if len(costs) != 0 {
return nil, fmt.Errorf("unexpected attendance costs")
}
e.calls++
if _, err := e.wallet.GrantQuestOnce(identity, rewards); err != nil {
return nil, err
}
var items []gamedata.BattleReward
for _, r := range rewards {
if r.Type == 9 || r.Type == 8 {
items = append(items, gamedata.BattleReward{Type: r.Type, ID: r.ID, Count: r.Count})
}
}
if _, err := e.inventory.GrantOnce(identity, items); err != nil {
return nil, err
}
return wire.AppendBytes(nil, 1, nil), nil
}
func TestAttendanceBatchRollsBackAndSuccessfulRetryGrantsOnce(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
now := time.Now().UTC()
registry := events.NewRegistry()
if err := registry.Replace([]events.Schedule{
{UID: 1, Type: 0, ID: 1, Start: now.Add(-24 * time.Hour).UnixMilli(), End: now.Add(48 * time.Hour).UnixMilli()},
{UID: 2, Type: 1, ID: 2, Start: now.Truncate(24 * time.Hour).UnixMilli(), End: now.Add(48 * time.Hour).UnixMilli()},
}); err != nil {
t.Fatal(err)
}
design := &gamedata.EventTasksDesign{
Attendance: map[uint64]gamedata.EventAttendance{1: {ID: 1, Group: 1}},
AttendanceRewards: map[uint64][]gamedata.EventAttendanceReward{1: {{ID: 1, Group: 1, Day: 1, Basic: gamedata.Reward{Type: 4, Count: 100}}}},
LimitRewards: map[[2]uint64]uint64{{2, 1}: 987},
}
open := func() (*accountstate.Repository, *player.Wallet, *player.Inventory, *eventtasks.Service, *mail.Service, *attendanceTransactionEconomy) {
t.Helper()
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = repo.Close() })
wallet, err := player.OpenWallet(repo, player.Currency{Gold: 12})
if err != nil {
t.Fatal(err)
}
inv, err := player.OpenInventory(repo, &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
eco := &attendanceTransactionEconomy{wallet: wallet, inventory: inv}
svc, err := eventtasks.Open(repo, design, registry, eco)
if err != nil {
t.Fatal(err)
}
mailbox, err := mail.OpenService(repo, &mail.Starter{Version: "2.35.10", MailCount: 1}, inv, wallet)
if err != nil {
t.Fatal(err)
}
if err := mailbox.AttachAttendanceRewardEconomy(eco); err != nil {
t.Fatal(err)
}
svc.AttachAttendanceMail(mailbox)
return repo, wallet, inv, svc, mailbox, eco
}
makeServer := func(repo *accountstate.Repository, svc *eventtasks.Service, mailbox *mail.Service) *Server {
t.Helper()
server, err := NewServer(fakeLogin{}, svc, mailbox, &mutatingDomain{store: repo})
if err != nil {
t.Fatal(err)
}
if err := server.AttachStateStore(repo); err != nil {
t.Fatal(err)
}
return server
}
batch := func(server *Server, cookie, endpoint string, seq uint64, fail bool) error {
t.Helper()
request := wire.AppendVarint(nil, 1, seq)
if endpoint == "/MailOpen" {
request = wire.AppendVarint(request, 2, 1)
}
requests := []protocol.BatchRequest{{Path: endpoint, RequestData: base64.StdEncoding.EncodeToString(request)}}
if fail {
requests = append(requests, protocol.BatchRequest{Path: "/InjectedFailure", RequestData: base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, seq+1))})
}
plain, err := json.Marshal(requests)
if err != nil {
t.Fatal(err)
}
body, err := cryptox.EncryptBase64(plain, server.KeyForTest())
if err != nil {
t.Fatal(err)
}
_, err = server.DispatchRaw("/BatchRequest", []byte(body), "s="+cookie)
return err
}
mailState := func(repo *accountstate.Repository) map[string]any {
t.Helper()
result := map[string]any{}
core, err := repo.Load("mail")
if err != nil {
t.Fatal(err)
}
result["core"] = string(core)
for _, bucket := range []string{"dynamic", "issued", "history"} {
entries, err := repo.ListEntries("mail", bucket)
if err != nil {
t.Fatal(err)
}
result[bucket] = entries
}
return result
}
assertUnclaimed := func(wallet *player.Wallet, inv *player.Inventory) {
t.Helper()
if wallet.Snapshot().Gold != 12 || len(inv.All()) != 0 {
t.Fatalf("unclaimed mail credited rewards: wallet=%+v items=%+v", wallet.Snapshot(), inv.All())
}
}
repo, wallet, inv, svc, mailbox, eco := open()
if err := wallet.EnsurePersisted(); err != nil {
t.Fatal(err)
}
if err := inv.EnsurePersisted(); err != nil {
t.Fatal(err)
}
if err := svc.SetNewbieStep(0); err != nil {
t.Fatal(err)
}
before, err := repo.Load("eventtasks")
if err != nil {
t.Fatal(err)
}
beforeMail := mailState(repo)
server := makeServer(repo, svc, mailbox)
session := login(t, server)
if err := batch(server, session.Cookie, "/Attendance", 2, true); err == nil {
t.Fatal("failed attendance batch accepted")
}
if eco.calls != 0 {
t.Fatalf("attendance credited economy instead of mailing rewards: calls=%d", eco.calls)
}
if err := repo.Close(); err != nil {
t.Fatal(err)
}
// Rollback fences the old domain objects; reopen every repository/domain.
repo, wallet, inv, svc, mailbox, eco = open()
after, err := repo.Load("eventtasks")
if err != nil {
t.Fatal(err)
}
if string(after) != string(before) {
t.Fatalf("attendance progress, claim markers or receipts survived rollback: %s", after)
}
if !reflect.DeepEqual(mailState(repo), beforeMail) {
t.Fatal("attendance mail or issued ledger survived rollback")
}
assertUnclaimed(wallet, inv)
server = makeServer(repo, svc, mailbox)
session = login(t, server)
if err := batch(server, session.Cookie, "/Attendance", 2, false); err != nil {
t.Fatal(err)
}
if err := batch(server, session.Cookie, "/Attendance", 3, false); err != nil {
t.Fatal(err)
}
if eco.calls != 0 {
t.Fatalf("attendance mail prematurely credited economy: calls=%d", eco.calls)
}
assertUnclaimed(wallet, inv)
issuedMail := mailState(repo)
if len(issuedMail["dynamic"].(map[string][]byte)) != 1 || len(issuedMail["issued"].(map[string][]byte)) != 1 {
t.Fatal("attendance did not issue exactly one mail")
}
if err := repo.Close(); err != nil {
t.Fatal(err)
}
repo, wallet, inv, svc, mailbox, eco = open()
server = makeServer(repo, svc, mailbox)
session = login(t, server)
if err := batch(server, session.Cookie, "/Attendance", 2, false); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(mailState(repo), issuedMail) {
t.Fatal("relogin duplicated attendance mail")
}
assertUnclaimed(wallet, inv)
if err := batch(server, session.Cookie, "/MailOpen", 3, true); err == nil {
t.Fatal("failed mailbox batch accepted")
}
if eco.calls != 1 {
t.Fatalf("mail claim did not reach real economy before rollback: calls=%d", eco.calls)
}
if err := repo.Close(); err != nil {
t.Fatal(err)
}
repo, wallet, inv, svc, mailbox, eco = open()
if !reflect.DeepEqual(mailState(repo), issuedMail) {
t.Fatal("mail claim/history persisted after failed batch")
}
assertUnclaimed(wallet, inv)
server = makeServer(repo, svc, mailbox)
session = login(t, server)
if err := batch(server, session.Cookie, "/MailOpen", 2, false); err != nil {
t.Fatal(err)
}
if err := batch(server, session.Cookie, "/MailOpen", 2, false); err != nil {
t.Fatal(err)
}
if err := batch(server, session.Cookie, "/MailOpen", 3, false); err != nil {
t.Fatal(err)
}
if wallet.Snapshot().Gold != 112 || len(inv.All()) != 1 || inv.All()[0].Count != 1 {
t.Fatal("mail replay duplicated persisted rewards")
}
if err := repo.Close(); err != nil {
t.Fatal(err)
}
repo, wallet, inv, svc, mailbox, eco = open()
defer repo.Close()
items := inv.All()
if wallet.Snapshot().Gold != 112 || len(items) != 1 || items[0].Type != 9 || items[0].ID != 987 || items[0].Count != 1 {
t.Fatalf("successful attendance not persisted exactly once: wallet=%+v items=%+v", wallet.Snapshot(), items)
}
for _, domain := range []string{"wallet", "items"} {
grants, err := repo.ListEntries(domain, "granted")
if err != nil {
t.Fatal(err)
}
if len(grants) != 1 {
t.Fatalf("%s durable grant ledger duplicated or missing: %v", domain, grants)
}
}
claimedMail := mailState(repo)
var claimed struct {
Opened []uint64 `json:"opened"`
}
if err := json.Unmarshal([]byte(claimedMail["core"].(string)), &claimed); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(claimed.Opened, []uint64{1}) || len(claimedMail["history"].(map[string][]byte)) != 1 {
t.Fatal("mail claim/history was not persisted")
}
server = makeServer(repo, svc, mailbox)
session = login(t, server)
if err := batch(server, session.Cookie, "/MailOpen", 2, false); err != nil {
t.Fatal(err)
}
if wallet.Snapshot().Gold != 112 || len(inv.All()) != 1 || inv.All()[0].Count != 1 {
t.Fatal("reopened mail claim duplicated rewards")
}
var state struct {
Attendance map[string]struct {
Count uint64
LastDay string
Obtained, History map[string]bool
}
LoginDays map[string]int64
}
saved, err := repo.Load("eventtasks")
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(saved, &state); err != nil {
t.Fatal(err)
}
if len(state.Attendance) != 2 || len(state.LoginDays) != 1 {
t.Fatalf("successful attendance progress missing: %s", saved)
}
for uid, a := range state.Attendance {
if a.Count != 1 || a.LastDay == "" || len(a.Obtained) != 1 || len(a.History) != 1 {
t.Fatalf("claim markers missing for %s: %+v", uid, a)
}
}
}