Files
apskel-pos-backend/internal/processor/enakgame_db_test.go
T

975 lines
47 KiB
Go
Raw Normal View History

package processor
import (
"context"
"encoding/json"
"os"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// EnakGame admin, start, refund and the session job against Postgres
// (docs/rfc-enakgame.md §7.1, §7.3, §11). Needs TEST_DATABASE_URL; see
// internal/repository/wallet_repository_test.go.
type enakGameEnv struct {
t *testing.T
db *gorm.DB
admin *EnakGameAdminProcessor
budgets *GameBudgetProcessor
sessions *GameSessionProcessor
events *GameEventProcessor
vouchers *VoucherAdminProcessor
redeem *VoucherRedemptionProcessor
exchange *WalletExchangeProcessor
provider *fakeVoucherProvider
wallet *WalletProcessor
txm *repository.TxManager
orgA uuid.UUID
orgB uuid.UUID
alice uuid.UUID // customer of A
bob uuid.UUID // customer of A
carol uuid.UUID // customer of B
manager uuid.UUID
now time.Time
}
func newEnakGameEnv(t *testing.T) *enakGameEnv {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
e := &enakGameEnv{
t: t, db: db, txm: repository.NewTxManager(db),
orgA: uuid.New(), orgB: uuid.New(), alice: uuid.New(), bob: uuid.New(), carol: uuid.New(), manager: uuid.New(),
now: time.Now(),
}
orgs := []uuid.UUID{e.orgA, e.orgB}
for _, org := range orgs {
require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'enakgame test', 'basic')`, org).Error)
}
require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'Alice'), (?, ?, 'Bob'), (?, ?, 'Carol')`,
e.alice, e.orgA, e.bob, e.orgA, e.carol, e.orgB).Error)
t.Cleanup(func() {
for _, q := range []string{
`DELETE FROM audit_logs WHERE organization_id IN ?`,
`DELETE FROM voucher_redemption_costs WHERE redemption_id IN (SELECT id FROM voucher_redemptions WHERE organization_id IN ?)`,
`DELETE FROM voucher_redemptions WHERE organization_id IN ?`,
`DELETE FROM voucher_codes WHERE voucher_id IN (SELECT id FROM vouchers WHERE organization_id IN ?)`,
`DELETE FROM vouchers WHERE organization_id IN ?`,
`DELETE FROM customer_security_events WHERE customer_id IN (SELECT id FROM customers WHERE organization_id IN ?)`,
`DELETE FROM game_reward_counters WHERE organization_id IN ?`,
`DELETE FROM game_session_rewards WHERE session_id IN (SELECT id FROM game_sessions WHERE organization_id IN ?)`,
`DELETE FROM game_sessions WHERE organization_id IN ?`,
`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE organization_id IN ?)`,
`DELETE FROM wallet_lots WHERE organization_id IN ?`,
`DELETE FROM wallet_transactions WHERE organization_id IN ?`,
`DELETE FROM customer_wallets WHERE organization_id IN ?`,
`DELETE FROM game_event_games WHERE event_id IN (SELECT id FROM game_events WHERE organization_id IN ?)`,
`DELETE FROM game_events WHERE organization_id IN ?`,
`DELETE FROM game_reward_configs WHERE organization_id IN ?`,
`DELETE FROM game_budgets WHERE organization_id IN ?`,
`DELETE FROM games WHERE organization_id IN ?`,
`DELETE FROM customers WHERE organization_id IN ?`,
`DELETE FROM organizations WHERE id IN ?`,
} {
db.Exec(q, orgs)
}
})
audit := NewAuditLogger(repository.NewAuditLogRepository(db))
games := repository.NewEnakGameRepository(db)
budgets := repository.NewGameBudgetRepository(db)
e.wallet = NewWalletProcessor(repository.NewWalletRepository(db))
e.admin = NewEnakGameAdminProcessor(games, audit, e.txm)
e.budgets = NewGameBudgetProcessor(budgets, audit, e.txm)
e.sessions = NewGameSessionProcessor(repository.NewWalletMoveRepository(db), games, repository.NewGameSessionRepository(db),
budgets, repository.NewGameEventRepository(db), repository.NewGameRewardCounterRepository(db), NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), e.txm), repository.NewWalletQueryRepository(db), e.wallet, audit, e.txm)
settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), e.txm)
pins := NewCustomerPinProcessor(repository.NewCustomerPinRepository(db), nil, nil)
vouchers := repository.NewVoucherRepository(db)
e.provider = &fakeVoucherProvider{issued: map[uuid.UUID]*VoucherIssue{}}
e.events = NewGameEventProcessor(repository.NewGameEventRepository(db), games, budgets, audit, e.txm)
e.vouchers = NewVoucherAdminProcessor(vouchers, audit, e.txm)
e.redeem = NewVoucherRedemptionProcessor(repository.NewWalletMoveRepository(db), vouchers, repository.NewVoucherRedemptionRepository(db),
pins, repository.NewWalletQueryRepository(db), e.wallet, VoucherProviders{"acme": e.provider}, e.txm)
e.exchange = NewWalletExchangeProcessor(repository.NewWalletMoveRepository(db), settings, repository.NewWalletQueryRepository(db), pins, e.wallet, e.txm)
hash, err := bcrypt.GenerateFromPassword([]byte(testPin), bcrypt.MinCost)
require.NoError(t, err)
require.NoError(t, db.Exec(`UPDATE customers SET pin_hash = ?, pin_set_at = NOW() WHERE organization_id IN ?`, string(hash), orgs).Error)
clock := func() time.Time { return e.now }
e.budgets.now, e.sessions.now, e.wallet.now, e.redeem.now, e.exchange.now = clock, clock, clock, clock, clock
return e
}
// testPin is every test customer's PIN.
const testPin = "482913"
func (e *enakGameEnv) ctx() context.Context { return context.Background() }
// coins gives a customer EnakCoin in one lot expiring at expiresAt (nil: never).
func (e *enakGameEnv) coins(customer uuid.UUID, amount int64, expiresAt *time.Time) {
e.t.Helper()
require.NoError(e.t, e.txm.WithTransaction(e.ctx(), func(ctx context.Context) error {
_, err := e.wallet.Credit(ctx, WalletCreditInput{
WalletEntry: WalletEntry{
CustomerID: customer, Currency: constants.WalletCurrencyCoin, Type: constants.WalletTxTypeMigration,
Amount: amount, ReferenceType: constants.WalletRefTypeLegacyTokens, ReferenceID: uuid.New(), Description: "Saldo awal",
},
Lots: []WalletLotInput{{Amount: amount, ExpiresAt: expiresAt}},
})
return err
}))
}
func (e *enakGameEnv) coinBalance(customer uuid.UUID) int64 {
e.t.Helper()
var balance int64
require.NoError(e.t, e.db.Raw(`SELECT COALESCE((SELECT coin_balance FROM customer_wallets WHERE customer_id = ?), 0)`, customer).Scan(&balance).Error)
return balance
}
func (e *enakGameEnv) count(query string, args ...any) int64 {
e.t.Helper()
var n int64
require.NoError(e.t, e.db.Raw(query, args...).Scan(&n).Error)
return n
}
// playableGame makes an ACTIVE game of org with an active FIXED config, and a global
// budget for the month if there is none.
func (e *enakGameEnv) playableGame(org uuid.UUID, slug string, entryCost int64) *models.EnakGame {
e.t.Helper()
game, err := e.admin.CreateGame(e.ctx(), org, e.manager, models.EnakGameInput{
Name: "Game " + slug, Slug: slug, Status: constants.GameStatusActive, EntryCost: entryCost,
})
require.NoError(e.t, err)
config, err := e.admin.CreateRewardConfig(e.ctx(), org, e.manager, game.ID, models.GameRewardConfigInput{
RewardType: constants.GameRewardTypeFixed, Rules: json.RawMessage(`{"amount": 10}`), MaxReward: 10,
})
require.NoError(e.t, err)
_, err = e.admin.ActivateRewardConfig(e.ctx(), org, e.manager, config.ID, models.GameRewardConfigActivateInput{})
require.NoError(e.t, err)
if e.count(`SELECT COUNT(*) FROM game_budgets WHERE organization_id = ? AND scope = 'GLOBAL'`, org) == 0 {
e.globalBudget(org, e.now)
}
return game
}
func (e *enakGameEnv) globalBudget(org uuid.UUID, day time.Time) *models.GameBudget {
e.t.Helper()
d := day.In(walletDisplayLocation)
start := time.Date(d.Year(), d.Month(), 1, 0, 0, 0, 0, time.UTC)
budget, err := e.budgets.CreateBudget(e.ctx(), org, e.manager, models.GameBudgetInput{
Scope: constants.GameBudgetScopeGlobal, Name: "Bulanan", Amount: 100_000_000,
PeriodStart: start.Format("2006-01-02"), PeriodEnd: start.AddDate(0, 1, -1).Format("2006-01-02"),
})
require.NoError(e.t, err)
return budget
}
func (e *enakGameEnv) auditActions(entityType string, id uuid.UUID) []string {
e.t.Helper()
var actions []string
require.NoError(e.t, e.db.Raw(`SELECT action FROM audit_logs WHERE entity_type = ? AND entity_id = ? ORDER BY created_at, action`, entityType, id).Scan(&actions).Error)
return actions
}
// EG-201
func TestEnakGameAdmin_Games(t *testing.T) {
e := newEnakGameEnv(t)
ctx := e.ctx()
game, err := e.admin.CreateGame(ctx, e.orgA, e.manager, models.EnakGameInput{Name: " Tap Tap ", Slug: "tap-tap", EntryCost: 3})
require.NoError(t, err)
assert.Equal(t, "Tap Tap", game.Name)
assert.Equal(t, constants.GameStatusDraft, game.Status, "a new game starts as a draft")
assert.Equal(t, "MINIGAME", game.Type)
assert.Equal(t, 600, game.SessionTTLSeconds)
for name, in := range map[string]models.EnakGameInput{
"no name": {Slug: "a", EntryCost: 1},
"bad slug": {Name: "x", Slug: "Tap Tap", EntryCost: 1},
"free": {Name: "x", Slug: "free", EntryCost: 0},
"archived on start": {Name: "x", Slug: "arch", EntryCost: 1, Status: "ARCHIVED"},
"unknown type": {Name: "x", Slug: "t", EntryCost: 1, Type: "PUZZLE"},
"negative max": {Name: "x", Slug: "m", EntryCost: 1, ResultRules: entities.GameResultRules{MaxScore: ptr(int64(-1))}},
"same outcome": {Name: "x", Slug: "o", EntryCost: 1, ResultRules: entities.GameResultRules{Outcomes: []string{"WIN", "WIN"}}},
"slug in use": {Name: "x", Slug: "tap-tap", EntryCost: 1},
} {
_, err := e.admin.CreateGame(ctx, e.orgA, e.manager, in)
assert.ErrorIs(t, err, ErrEnakGameRejected, name)
}
_, err = e.admin.CreateGame(ctx, e.orgB, e.manager, models.EnakGameInput{Name: "Tap", Slug: "tap-tap", EntryCost: 1})
require.NoError(t, err, "another organization may use the slug")
// Organization B sees nothing of A's game.
_, err = e.admin.GetGame(ctx, e.orgB, game.ID)
assert.ErrorIs(t, err, repository.ErrEnakGameNotFound)
_, err = e.admin.UpdateGame(ctx, e.orgB, e.manager, game.ID, EnakGameInputFrom(game))
assert.ErrorIs(t, err, repository.ErrEnakGameNotFound)
_, err = e.admin.SetGameStatus(ctx, e.orgB, e.manager, game.ID, models.EnakGameStatusInput{Status: "ACTIVE"})
assert.ErrorIs(t, err, repository.ErrEnakGameNotFound)
list, err := e.admin.ListGames(ctx, e.orgB, models.EnakGameListQuery{})
require.NoError(t, err)
require.Len(t, list.Data, 1)
assert.NotEqual(t, game.ID, list.Data[0].ID)
in := EnakGameInputFrom(game)
in.EntryCost = 5
updated, err := e.admin.UpdateGame(ctx, e.orgA, e.manager, game.ID, in)
require.NoError(t, err)
assert.EqualValues(t, 5, updated.EntryCost)
assert.Equal(t, constants.GameStatusDraft, updated.Status, "an update does not touch the status")
reason := "launch"
_, err = e.admin.SetGameStatus(ctx, e.orgA, e.manager, game.ID, models.EnakGameStatusInput{Status: "active", Reason: &reason})
require.NoError(t, err)
var audit struct {
ActorID string
Before string
After string
Reason string
}
require.NoError(t, e.db.Raw(`SELECT actor_id::text AS actor_id, before::text AS before, after::text AS after, reason FROM audit_logs
WHERE entity_id = ? AND action = 'STATUS_CHANGED'`, game.ID).Scan(&audit).Error)
assert.Equal(t, e.manager.String(), audit.ActorID)
assert.JSONEq(t, `{"status": "DRAFT"}`, audit.Before)
assert.JSONEq(t, `{"status": "ACTIVE"}`, audit.After)
assert.Equal(t, "launch", audit.Reason)
// Archived: hidden from the default list, never changed again.
_, err = e.admin.SetGameStatus(ctx, e.orgA, e.manager, game.ID, models.EnakGameStatusInput{Status: "ARCHIVED"})
require.NoError(t, err)
_, err = e.admin.SetGameStatus(ctx, e.orgA, e.manager, game.ID, models.EnakGameStatusInput{Status: "ACTIVE"})
assert.ErrorIs(t, err, ErrEnakGameRejected)
_, err = e.admin.UpdateGame(ctx, e.orgA, e.manager, game.ID, in)
assert.ErrorIs(t, err, ErrEnakGameRejected)
list, err = e.admin.ListGames(ctx, e.orgA, models.EnakGameListQuery{})
require.NoError(t, err)
assert.Empty(t, list.Data)
list, err = e.admin.ListGames(ctx, e.orgA, models.EnakGameListQuery{Status: "archived"})
require.NoError(t, err)
assert.Len(t, list.Data, 1)
assert.Equal(t, []string{"CREATED", "UPDATED", "STATUS_CHANGED", "STATUS_CHANGED"}, e.auditActions(constants.AuditEntityGame, game.ID))
}
// EG-202
func TestEnakGameAdmin_RewardConfigs(t *testing.T) {
e := newEnakGameEnv(t)
ctx := e.ctx()
game, err := e.admin.CreateGame(ctx, e.orgA, e.manager, models.EnakGameInput{Name: "Runner", Slug: "runner", EntryCost: 1})
require.NoError(t, err)
newVersion := func(amount int) *models.GameRewardConfig {
t.Helper()
c, err := e.admin.CreateRewardConfig(ctx, e.orgA, e.manager, game.ID, models.GameRewardConfigInput{
RewardType: "fixed", Rules: json.RawMessage(`{ "amount" : ` + string(rune('0'+amount)) + ` }`), MaxReward: 10,
})
require.NoError(t, err)
return c
}
v1, v2 := newVersion(8), newVersion(9)
assert.Equal(t, []int{1, 2}, []int{v1.Version, v2.Version})
assert.Equal(t, constants.GameRewardConfigStatusDraft, v1.Status)
assert.JSONEq(t, `{"amount": 8}`, string(v1.Rules))
for name, in := range map[string]models.GameRewardConfigInput{
"unknown type": {RewardType: "TIERED", Rules: json.RawMessage(`{}`)},
"no rules": {RewardType: "FIXED"},
"rules invalid": {RewardType: "SCORE_BASED", Rules: json.RawMessage(`{"bands": [{"min": 5, "amount": 1}]}`)},
"negative max": {RewardType: "FIXED", Rules: json.RawMessage(`{"amount": 1}`), MaxReward: -1},
"unknown rule key": {RewardType: "FIXED", Rules: json.RawMessage(`{"amount": 1, "bonus": 2}`)},
} {
_, err := e.admin.CreateRewardConfig(ctx, e.orgA, e.manager, game.ID, in)
assert.ErrorIs(t, err, ErrEnakGameRejected, name)
}
_, err = e.admin.CreateRewardConfig(ctx, e.orgB, e.manager, game.ID, models.GameRewardConfigInput{RewardType: "FIXED", Rules: json.RawMessage(`{"amount": 1}`)})
assert.ErrorIs(t, err, repository.ErrEnakGameNotFound, "another organization's game")
_, err = e.admin.ActivateRewardConfig(ctx, e.orgB, e.manager, v1.ID, models.GameRewardConfigActivateInput{})
assert.ErrorIs(t, err, repository.ErrGameRewardConfigNotFound)
// Two versions activated at once: exactly one ends up active.
var wg sync.WaitGroup
for _, v := range []*models.GameRewardConfig{v1, v2} {
v := v
wg.Add(1)
go func() {
defer wg.Done()
_, err := e.admin.ActivateRewardConfig(ctx, e.orgA, e.manager, v.ID, models.GameRewardConfigActivateInput{})
assert.NoError(t, err)
}()
}
wg.Wait()
configs, err := e.admin.ListRewardConfigs(ctx, e.orgA, game.ID)
require.NoError(t, err)
active := 0
for _, c := range configs {
if c.Status == constants.GameRewardConfigStatusActive {
active++
}
}
assert.Equal(t, 1, active)
// Activating the active one again changes nothing; a retired one stays retired.
var retired *models.GameRewardConfig
for i := range configs {
if configs[i].Status == constants.GameRewardConfigStatusRetired {
retired = &configs[i]
}
}
require.NotNil(t, retired, "the version activated first was retired by the second")
_, err = e.admin.ActivateRewardConfig(ctx, e.orgA, e.manager, retired.ID, models.GameRewardConfigActivateInput{})
assert.ErrorIs(t, err, ErrEnakGameRejected)
// Nothing but status ever changed: the rules are as created.
for _, c := range configs {
want := map[uuid.UUID]string{v1.ID: `{"amount": 8}`, v2.ID: `{"amount": 9}`}[c.ID]
assert.JSONEq(t, want, string(c.Rules))
}
assert.Equal(t, int64(2), e.count(`SELECT COUNT(*) FROM audit_logs WHERE entity_type = 'GAME_REWARD_CONFIG' AND action = 'CREATED' AND organization_id = ?`, e.orgA))
assert.Equal(t, int64(2), e.count(`SELECT COUNT(*) FROM audit_logs WHERE entity_type = 'GAME_REWARD_CONFIG' AND action = 'ACTIVATED' AND organization_id = ?`, e.orgA))
assert.Equal(t, int64(1), e.count(`SELECT COUNT(*) FROM audit_logs WHERE entity_type = 'GAME_REWARD_CONFIG' AND action = 'RETIRED' AND organization_id = ?`, e.orgA))
}
// EG-203
func TestEnakGameAdmin_Budgets(t *testing.T) {
e := newEnakGameEnv(t)
ctx := e.ctx()
e.now = time.Date(2026, 10, 15, 12, 0, 0, 0, walletDisplayLocation)
october, err := e.budgets.CreateBudget(ctx, e.orgA, e.manager, models.GameBudgetInput{
Scope: "global", Name: "Oktober", PeriodStart: "2026-10-01", PeriodEnd: "2026-10-31", Amount: 100_000_000,
Thresholds: models.GameBudgetThresholds{Warning: ptr(int64(70)), Critical: ptr(int64(90))},
})
require.NoError(t, err)
assert.Equal(t, "2026-10-01", october.PeriodStart)
assert.EqualValues(t, 90, *october.Thresholds.Critical)
for name, in := range map[string]models.GameBudgetInput{
"overlaps": {Scope: "GLOBAL", Name: "x", PeriodStart: "2026-10-20", PeriodEnd: "2026-11-19", Amount: 1},
"same start": {Scope: "GLOBAL", Name: "x", PeriodStart: "2026-10-01", PeriodEnd: "2026-10-01", Amount: 1},
"end before": {Scope: "GLOBAL", Name: "x", PeriodStart: "2026-12-02", PeriodEnd: "2026-12-01", Amount: 1},
"no amount": {Scope: "GLOBAL", Name: "x", PeriodStart: "2026-12-01", PeriodEnd: "2026-12-31"},
"bad date": {Scope: "GLOBAL", Name: "x", PeriodStart: "1/12/2026", PeriodEnd: "2026-12-31", Amount: 1},
"unknown scope": {Scope: "OUTLET", Name: "x", PeriodStart: "2026-12-01", PeriodEnd: "2026-12-31", Amount: 1},
"warning > crit": {Scope: "EVENT", Name: "x", PeriodStart: "2026-12-01", PeriodEnd: "2026-12-31", Amount: 1, Thresholds: models.GameBudgetThresholds{Warning: ptr(int64(95)), Critical: ptr(int64(90))}},
"threshold > 100": {Scope: "EVENT", Name: "x", PeriodStart: "2026-12-01", PeriodEnd: "2026-12-31", Amount: 1, Thresholds: models.GameBudgetThresholds{Critical: ptr(int64(101))}},
} {
_, err := e.budgets.CreateBudget(ctx, e.orgA, e.manager, in)
assert.ErrorIs(t, err, ErrEnakGameRejected, name)
}
_, err = e.budgets.CreateBudget(ctx, e.orgA, e.manager, models.GameBudgetInput{Scope: "EVENT", Name: "Ramadan", PeriodStart: "2026-10-10", PeriodEnd: "2026-10-20", Amount: 5_000_000})
require.NoError(t, err, "an event budget may share days with the global one")
// A change records the amount before and after.
in := GameBudgetInputFrom(october)
in.Amount = 120_000_000
updated, err := e.budgets.UpdateBudget(ctx, e.orgA, e.manager, october.ID, in)
require.NoError(t, err)
assert.EqualValues(t, 120_000_000, updated.Amount)
var change struct{ Before, After string }
require.NoError(t, e.db.Raw(`SELECT before->>'amount' AS before, after->>'amount' AS after FROM audit_logs WHERE entity_id = ? AND action = 'UPDATED'`, october.ID).Scan(&change).Error)
assert.Equal(t, struct{ Before, After string }{"100000000", "120000000"}, change)
in.Scope = "EVENT"
_, err = e.budgets.UpdateBudget(ctx, e.orgA, e.manager, october.ID, in)
assert.ErrorIs(t, err, ErrEnakGameRejected, "the scope stays")
_, err = e.budgets.UpdateBudget(ctx, e.orgB, e.manager, october.ID, GameBudgetInputFrom(october))
assert.ErrorIs(t, err, repository.ErrGameBudgetNotFound)
// The period job opens November once, however often it runs. It works across
// organizations, so this organization's budgets are what is checked.
for run := 0; run < 3; run++ {
_, err := e.budgets.CreateNextPeriods(ctx)
require.NoError(t, err)
assert.Equal(t, int64(2), e.count(`SELECT COUNT(*) FROM game_budgets WHERE organization_id = ? AND scope = 'GLOBAL'`, e.orgA), "run %d", run)
}
list, err := e.budgets.ListBudgets(ctx, e.orgA, models.GameBudgetListQuery{Scope: "GLOBAL"})
require.NoError(t, err)
require.Len(t, list.Data, 2)
november := list.Data[0]
assert.Equal(t, "2026-11-01", november.PeriodStart)
assert.Equal(t, "2026-11-30", november.PeriodEnd)
assert.EqualValues(t, 120_000_000, november.Amount)
assert.EqualValues(t, 70, *november.Thresholds.Warning)
assert.Equal(t, int64(1), e.count(`SELECT COUNT(*) FROM audit_logs WHERE entity_id = ? AND actor_type = 'SYSTEM'`, november.ID))
// A budget a reward points at stays; an unused one goes.
game := e.playableGame(e.orgA, "budget-in-use", 1)
e.coins(e.alice, 5, nil)
started, err := e.sessions.Start(ctx, e.alice, game.ID, "k1")
require.NoError(t, err)
reward := uuid.New()
require.NoError(t, e.db.Exec(`INSERT INTO wallet_transactions (id, organization_id, customer_id, currency, type, amount, balance_after, reference_type, reference_id, description)
VALUES (?, ?, ?, 'COIN', 'GAME_REWARD', 1, 0, 'GAME_SESSION', ?, 'x')`, reward, e.orgA, e.alice, started.SessionID).Error)
require.NoError(t, e.db.Exec(`INSERT INTO game_session_rewards (session_id, budget_id, amount, wallet_transaction_id) VALUES (?, ?, 1, ?)`,
started.SessionID, october.ID, reward).Error)
assert.ErrorIs(t, e.budgets.DeleteBudget(ctx, e.orgA, e.manager, october.ID), ErrEnakGameRejected)
require.NoError(t, e.budgets.DeleteBudget(ctx, e.orgA, e.manager, november.ID))
assert.Equal(t, []string{"CREATED", "DELETED"}, e.auditActions(constants.AuditEntityGameBudget, november.ID))
}
// EG-204
func TestGameSession_Start(t *testing.T) {
e := newEnakGameEnv(t)
ctx := e.ctx()
game := e.playableGame(e.orgA, "runner", 3)
e.coins(e.alice, 10, nil)
first, err := e.sessions.Start(ctx, e.alice, game.ID, "tap-1")
require.NoError(t, err)
assert.False(t, first.Replayed)
assert.EqualValues(t, 3, first.EntryCost)
assert.EqualValues(t, 7, first.CoinBalance)
assert.WithinDuration(t, e.now.Add(600*time.Second), first.ExpiresAt, time.Second)
// The same key again: the same session, nothing more taken.
again, err := e.sessions.Start(ctx, e.alice, game.ID, "tap-1")
require.NoError(t, err)
assert.True(t, again.Replayed)
assert.Equal(t, first.SessionID, again.SessionID)
assert.EqualValues(t, 7, e.coinBalance(e.alice))
assert.Equal(t, int64(1), e.count(`SELECT COUNT(*) FROM wallet_transactions WHERE customer_id = ? AND type = 'GAME_SPEND'`, e.alice))
assert.Equal(t, int64(1), e.count(`SELECT COUNT(*) FROM game_sessions WHERE customer_id = ?`, e.alice))
var spend struct {
ReferenceType string
ReferenceID string
Amount int64
}
require.NoError(t, e.db.Raw(`SELECT reference_type, reference_id::text AS reference_id, amount FROM wallet_transactions WHERE customer_id = ? AND type = 'GAME_SPEND'`, e.alice).Scan(&spend).Error)
assert.Equal(t, constants.WalletRefTypeGameSession, spend.ReferenceType)
assert.Equal(t, first.SessionID.String(), spend.ReferenceID)
assert.EqualValues(t, -3, spend.Amount)
// The session keeps what it started with.
configs, err := e.admin.ListRewardConfigs(ctx, e.orgA, game.ID)
require.NoError(t, err)
in := EnakGameInputFrom(game)
in.EntryCost = 9
_, err = e.admin.UpdateGame(ctx, e.orgA, e.manager, game.ID, in)
require.NoError(t, err)
v2, err := e.admin.CreateRewardConfig(ctx, e.orgA, e.manager, game.ID, models.GameRewardConfigInput{RewardType: "FIXED", Rules: json.RawMessage(`{"amount": 1}`)})
require.NoError(t, err)
_, err = e.admin.ActivateRewardConfig(ctx, e.orgA, e.manager, v2.ID, models.GameRewardConfigActivateInput{})
require.NoError(t, err)
var snapshot struct {
EntryCost int64
RewardConfigID string
}
require.NoError(t, e.db.Raw(`SELECT entry_cost, reward_config_id::text AS reward_config_id FROM game_sessions WHERE id = ?`, first.SessionID).Scan(&snapshot).Error)
assert.EqualValues(t, 3, snapshot.EntryCost)
assert.Equal(t, configs[0].ID.String(), snapshot.RewardConfigID)
_, err = e.sessions.Start(ctx, e.alice, uuid.New(), "tap-1")
assert.ErrorIs(t, err, ErrGameSessionRejected, "the key started another game")
// Refusals leave neither a ledger row nor a session.
inactive := e.playableGame(e.orgA, "inactive", 1)
_, err = e.admin.SetGameStatus(ctx, e.orgA, e.manager, inactive.ID, models.EnakGameStatusInput{Status: "INACTIVE"})
require.NoError(t, err)
unconfigured, err := e.admin.CreateGame(ctx, e.orgA, e.manager, models.EnakGameInput{Name: "x", Slug: "no-config", EntryCost: 1, Status: "ACTIVE"})
require.NoError(t, err)
theirs := e.playableGame(e.orgB, "theirs", 1)
e.coins(e.carol, 10, nil)
noBudget, err := e.admin.CreateGame(ctx, e.orgB, e.manager, models.EnakGameInput{Name: "x", Slug: "no-budget", EntryCost: 1, Status: "ACTIVE"})
require.NoError(t, err)
for name, tc := range map[string]struct {
customer uuid.UUID
game uuid.UUID
key string
want error
}{
"not enough EnakCoin": {e.bob, game.ID, "b1", ErrGameSessionRejected},
"another org's game": {e.alice, theirs.ID, "a2", repository.ErrEnakGameNotFound},
"inactive game": {e.alice, inactive.ID, "a3", ErrGameSessionRejected},
"no active config": {e.alice, unconfigured.ID, "a4", ErrGameSessionRejected},
"no key": {e.alice, game.ID, " ", ErrGameSessionRejected},
"key too long": {e.alice, game.ID, string(make([]byte, 51)), ErrGameSessionRejected},
"unknown customer": {uuid.New(), game.ID, "u1", repository.ErrWalletNotFound},
"unknown game": {e.alice, uuid.New(), "a5", repository.ErrEnakGameNotFound},
"no budget for period": {e.carol, noBudget.ID, "c1", nil},
} {
if name == "no budget for period" {
// B has a budget from playableGame; move it out of this period.
require.NoError(t, e.db.Exec(`UPDATE game_budgets SET period_start = '2000-01-01', period_end = '2000-01-31' WHERE organization_id = ?`, e.orgB).Error)
cfg, err := e.admin.CreateRewardConfig(ctx, e.orgB, e.manager, noBudget.ID, models.GameRewardConfigInput{RewardType: "FIXED", Rules: json.RawMessage(`{"amount": 1}`)})
require.NoError(t, err)
_, err = e.admin.ActivateRewardConfig(ctx, e.orgB, e.manager, cfg.ID, models.GameRewardConfigActivateInput{})
require.NoError(t, err)
tc.want = ErrGameSessionRejected
}
before := e.count(`SELECT COUNT(*) FROM wallet_transactions WHERE type = 'GAME_SPEND' AND organization_id IN (?, ?)`, e.orgA, e.orgB)
_, err := e.sessions.Start(ctx, tc.customer, tc.game, tc.key)
assert.ErrorIs(t, err, tc.want, name)
assert.Equal(t, before, e.count(`SELECT COUNT(*) FROM wallet_transactions WHERE type = 'GAME_SPEND' AND organization_id IN (?, ?)`, e.orgA, e.orgB), name)
}
assert.Equal(t, int64(1), e.count(`SELECT COUNT(*) FROM game_sessions WHERE organization_id IN (?, ?)`, e.orgA, e.orgB))
}
// EG-205
func TestGameSession_RefundAndJob(t *testing.T) {
e := newEnakGameEnv(t)
ctx := e.ctx()
game := e.playableGame(e.orgA, "runner", 4)
other := e.playableGame(e.orgA, "other", 4)
soon := e.now.Add(48 * time.Hour)
e.coins(e.alice, 100, &soon)
e.coins(e.bob, 100, nil)
start := func(customer, gameID uuid.UUID, key string) uuid.UUID {
t.Helper()
s, err := e.sessions.Start(ctx, customer, gameID, key)
require.NoError(t, err)
return s.SessionID
}
deactivated := start(e.alice, game.ID, "a1")
failed := start(e.alice, other.ID, "a2")
abandoned := start(e.bob, other.ID, "b1")
stillOpen := start(e.bob, other.ID, "b2")
require.NoError(t, e.db.Exec(`UPDATE game_sessions SET expires_at = ? WHERE id IN ?`, e.now.Add(-time.Minute), []uuid.UUID{failed, abandoned}).Error)
require.NoError(t, e.db.Exec(`UPDATE game_sessions SET completion_failed_at = ? WHERE id = ?`, e.now.Add(-2*time.Minute), failed).Error)
_, err := e.admin.SetGameStatus(ctx, e.orgA, e.manager, game.ID, models.EnakGameStatusInput{Status: "INACTIVE"})
require.NoError(t, err)
assert.EqualValues(t, 92, e.coinBalance(e.alice))
assert.EqualValues(t, 92, e.coinBalance(e.bob))
// Run the job twice: the second run finds nothing more to do here. The job works
// across organizations, and other tests may share the database, so what is
// checked is this organization's sessions, not the job's totals.
refunded, expired, err := e.sessions.ProcessDueSessions(ctx)
require.NoError(t, err)
assert.GreaterOrEqual(t, refunded, 2)
assert.GreaterOrEqual(t, expired, 1)
_, _, err = e.sessions.ProcessDueSessions(ctx)
require.NoError(t, err)
status := func(id uuid.UUID) (string, *string) {
var row struct {
Status string
RefundReason *string
}
require.NoError(t, e.db.Raw(`SELECT status, refund_reason FROM game_sessions WHERE id = ?`, id).Scan(&row).Error)
return row.Status, row.RefundReason
}
s, reason := status(deactivated)
assert.Equal(t, constants.GameSessionStatusRefunded, s, "refunded without waiting for its expiry")
assert.Equal(t, constants.GameSessionRefundGameDeactivated, *reason)
s, reason = status(failed)
assert.Equal(t, constants.GameSessionStatusRefunded, s)
assert.Equal(t, constants.GameSessionRefundSystemError, *reason)
s, reason = status(abandoned)
assert.Equal(t, constants.GameSessionStatusExpired, s, "left behind: no refund")
assert.Nil(t, reason)
s, _ = status(stillOpen)
assert.Equal(t, constants.GameSessionStatusStarted, s)
assert.EqualValues(t, 100, e.coinBalance(e.alice), "both entry costs came back")
assert.EqualValues(t, 92, e.coinBalance(e.bob), "abandoned and open sessions keep their cost")
assert.Equal(t, int64(2), e.count(`SELECT COUNT(*) FROM wallet_transactions WHERE customer_id = ? AND type = 'GAME_SPEND_REFUND'`, e.alice))
// The refund lot points at the lot the entry cost came from and lasts at least
// seven days, although that lot expires in two.
var lot struct {
OriginLotID string
ExpiresAt time.Time
Reverses string
}
require.NoError(t, e.db.Raw(`
SELECT l.origin_lot_id::text AS origin_lot_id, l.expires_at, t.reverses_transaction_id::text AS reverses
FROM game_sessions s
JOIN wallet_transactions t ON t.id = s.refund_transaction_id
JOIN wallet_lots l ON l.source_transaction_id = t.id
WHERE s.id = ?`, deactivated).Scan(&lot).Error)
var spendLot, spendTx string
require.NoError(t, e.db.Raw(`SELECT a.lot_id::text FROM wallet_lot_allocations a JOIN game_sessions s ON s.spend_transaction_id = a.transaction_id WHERE s.id = ?`, deactivated).Scan(&spendLot).Error)
require.NoError(t, e.db.Raw(`SELECT spend_transaction_id::text FROM game_sessions WHERE id = ?`, deactivated).Scan(&spendTx).Error)
assert.Equal(t, spendLot, lot.OriginLotID)
assert.Equal(t, spendTx, lot.Reverses)
assert.True(t, !lot.ExpiresAt.Before(e.now.Add(7*24*time.Hour-time.Minute)), "expires %s", lot.ExpiresAt)
assert.Equal(t, int64(2), e.count(`SELECT COUNT(*) FROM audit_logs WHERE entity_type = 'GAME_SESSION' AND action = 'REFUNDED' AND actor_type = 'SYSTEM' AND source = 'session_job' AND organization_id = ?`, e.orgA))
refundedAgain, err := e.sessions.RefundSession(ctx, e.orgA, deactivated, constants.GameSessionRefundSystemError, constants.AuditSourceSessionJob)
require.NoError(t, err)
assert.False(t, refundedAgain, "a session is refunded once")
// Completing and refunding the same session at once: exactly one wins.
sessionsRepo := repository.NewGameSessionRepository(e.db)
for round := 0; round < 5; round++ {
id := start(e.bob, other.ID, "race-"+string(rune('a'+round)))
var wg sync.WaitGroup
var completed, refundedNow bool
begin := make(chan struct{})
wg.Add(2)
go func() {
defer wg.Done()
<-begin
assert.NoError(t, e.txm.WithTransaction(ctx, func(ctx context.Context) error {
if err := e.wallet.LockWallet(ctx, e.bob); err != nil {
return err
}
var err error
completed, err = sessionsRepo.CompleteSession(ctx, id, repository.GameSessionCompletion{EndedAt: e.now})
return err
}))
}()
go func() {
defer wg.Done()
<-begin
var err error
refundedNow, err = e.sessions.RefundSession(ctx, e.orgA, id, constants.GameSessionRefundSystemError, constants.AuditSourceSessionJob)
assert.NoError(t, err)
}()
close(begin)
wg.Wait()
assert.True(t, completed != refundedNow, "round %d: completed %v, refunded %v", round, completed, refundedNow)
refunds := e.count(`SELECT COUNT(*) FROM wallet_transactions WHERE type = 'GAME_SPEND_REFUND' AND reference_id = ?`, id)
assert.Equal(t, map[bool]int64{true: 1, false: 0}[refundedNow], refunds, "round %d", round)
}
}
// EG-206
func TestGameSession_CustomerReads(t *testing.T) {
e := newEnakGameEnv(t)
ctx := e.ctx()
game := e.playableGame(e.orgA, "runner", 2)
_, err := e.admin.CreateGame(ctx, e.orgA, e.manager, models.EnakGameInput{Name: "Draft", Slug: "draft", EntryCost: 1})
require.NoError(t, err)
e.playableGame(e.orgB, "theirs", 1)
e.coins(e.alice, 10, nil)
games, err := e.sessions.ListGames(ctx, e.alice)
require.NoError(t, err)
require.Len(t, games, 1, "only the ACTIVE games of the customer's organization")
assert.Equal(t, game.ID, games[0].ID)
assert.Equal(t, "runner", games[0].Slug)
assert.EqualValues(t, 2, games[0].EntryCost)
started, err := e.sessions.Start(ctx, e.alice, game.ID, "k")
require.NoError(t, err)
require.NoError(t, e.db.Exec(`UPDATE game_sessions SET reward_breakdown = '{"roll": 7}', flagged = TRUE WHERE id = ?`, started.SessionID).Error)
session, err := e.sessions.GetSession(ctx, e.alice, started.SessionID)
require.NoError(t, err)
assert.Equal(t, constants.GameSessionStatusStarted, session.Status)
raw, err := json.Marshal(session)
require.NoError(t, err)
for _, internal := range []string{"reward_breakdown", "roll", "flagged", "result"} {
assert.NotContains(t, string(raw), internal)
}
_, err = e.sessions.GetSession(ctx, e.bob, started.SessionID)
assert.ErrorIs(t, err, repository.ErrGameSessionNotFound, "another customer's session")
page, err := e.sessions.ListSessions(ctx, e.alice, 1, 10)
require.NoError(t, err)
assert.EqualValues(t, 1, page.Pagination.Total)
page, err = e.sessions.ListSessions(ctx, e.bob, 1, 10)
require.NoError(t, err)
assert.Empty(t, page.Data)
}
// gameWith makes an ACTIVE game of org A with the given result rules and an active
// config of the given type, rules and cap.
func (e *enakGameEnv) gameWith(slug string, resultRules entities.GameResultRules, rewardType, rules string, maxReward int64) *models.EnakGame {
e.t.Helper()
game, err := e.admin.CreateGame(e.ctx(), e.orgA, e.manager, models.EnakGameInput{
Name: "Game " + slug, Slug: slug, Status: constants.GameStatusActive, EntryCost: 2, ResultRules: resultRules,
})
require.NoError(e.t, err)
config, err := e.admin.CreateRewardConfig(e.ctx(), e.orgA, e.manager, game.ID, models.GameRewardConfigInput{
RewardType: rewardType, Rules: json.RawMessage(rules), MaxReward: maxReward,
})
require.NoError(e.t, err)
_, err = e.admin.ActivateRewardConfig(e.ctx(), e.orgA, e.manager, config.ID, models.GameRewardConfigActivateInput{})
require.NoError(e.t, err)
if e.count(`SELECT COUNT(*) FROM game_budgets WHERE organization_id = ? AND scope = 'GLOBAL'`, e.orgA) == 0 {
e.globalBudget(e.orgA, e.now)
}
return game
}
func (e *enakGameEnv) session(id uuid.UUID) entities.GameSession {
e.t.Helper()
var s entities.GameSession
require.NoError(e.t, e.db.Where("id = ?", id).First(&s).Error)
return s
}
// failingRewards fails right after the reward credit, to show the whole completion
// rolls back.
type failingRewards struct {
repository.GameSessionRepository
}
func (failingRewards) CreateSessionRewards(context.Context, []entities.GameSessionReward) error {
return assert.AnError
}
// EG-303
func TestGameSession_Complete(t *testing.T) {
e := newEnakGameEnv(t)
ctx := e.ctx()
score := func(v int64) *int64 { return &v }
maxScore, minDuration := int64(1000), int64(5)
scored := e.gameWith("scored", entities.GameResultRules{MaxScore: &maxScore, MinDurationSeconds: &minDuration},
constants.GameRewardTypeScoreBased, `{"bands": [{"min": 0, "max": 100, "amount": 1}, {"min": 101, "amount": 20}]}`, 15)
e.coins(e.alice, 100, nil)
e.coins(e.bob, 100, nil)
start := func(customer, gameID uuid.UUID, key string) uuid.UUID {
t.Helper()
s, err := e.sessions.Start(ctx, customer, gameID, key)
require.NoError(t, err)
return s.SessionID
}
later := func(d time.Duration) { e.now = e.now.Add(d) }
t.Run("pays the base reward once, capped by max_reward", func(t *testing.T) {
id := start(e.alice, scored.ID, "a1")
later(30 * time.Second)
first, err := e.sessions.Complete(ctx, e.alice, id, models.GameSessionCompleteInput{Score: score(500)})
require.NoError(t, err)
assert.Equal(t, constants.GameSessionStatusCompleted, first.Status)
assert.EqualValues(t, 15, first.RewardTotal, "20 capped at 15")
assert.EqualValues(t, 15, first.Reward.Base)
assert.EqualValues(t, 100-2+15, first.CoinBalance)
second, err := e.sessions.Complete(ctx, e.alice, id, models.GameSessionCompleteInput{Score: score(999)})
require.NoError(t, err)
assert.Equal(t, first, second, "completing again answers the same")
assert.Equal(t, int64(1), e.count(`SELECT COUNT(*) FROM wallet_transactions WHERE type = 'GAME_REWARD' AND reference_id = ?`, id))
var reward struct {
Amount int64
IdempotencyKey string
BudgetID string
}
require.NoError(t, e.db.Raw(`SELECT t.amount, t.idempotency_key, r.budget_id::text AS budget_id
FROM game_session_rewards r JOIN wallet_transactions t ON t.id = r.wallet_transaction_id WHERE r.session_id = ?`, id).Scan(&reward).Error)
var global string
require.NoError(t, e.db.Raw(`SELECT id::text FROM game_budgets WHERE organization_id = ? AND scope = 'GLOBAL'`, e.orgA).Scan(&global).Error)
assert.EqualValues(t, 15, reward.Amount)
assert.Equal(t, "game-reward:"+id.String()+":"+global, reward.IdempotencyKey)
assert.Equal(t, global, reward.BudgetID)
s := e.session(id)
assert.False(t, s.Flagged)
var breakdown map[string]any
require.NoError(t, json.Unmarshal(s.RewardBreakdown, &breakdown))
assert.EqualValues(t, 20, breakdown["base"])
assert.Equal(t, true, breakdown["capped"])
assert.EqualValues(t, 1, breakdown["config_version"])
assert.JSONEq(t, `{"score": 500}`, string(s.Result))
})
t.Run("an unbelievable result earns nothing and is flagged", func(t *testing.T) {
for name, tc := range map[string]struct {
after time.Duration
in models.GameSessionCompleteInput
}{
"too fast": {time.Second, models.GameSessionCompleteInput{Score: score(10)}},
"score above max": {time.Minute, models.GameSessionCompleteInput{Score: score(5000)}},
"no score": {time.Minute, models.GameSessionCompleteInput{}},
} {
before := e.coinBalance(e.bob)
id := start(e.bob, scored.ID, "flag-"+name)
later(tc.after)
out, err := e.sessions.Complete(ctx, e.bob, id, tc.in)
require.NoError(t, err, name)
assert.Equal(t, constants.GameSessionStatusCompleted, out.Status, name)
assert.Zero(t, out.RewardTotal, name)
s := e.session(id)
assert.True(t, s.Flagged, name)
assert.Nil(t, s.RefundTransactionID, name)
assert.Contains(t, string(s.RewardBreakdown), "rejections", name)
assert.EqualValues(t, before-2, e.coinBalance(e.bob), "%s: the entry cost stays spent", name)
}
})
t.Run("someone else's session, an expired one", func(t *testing.T) {
id := start(e.alice, scored.ID, "a2")
_, err := e.sessions.Complete(ctx, e.bob, id, models.GameSessionCompleteInput{Score: score(1)})
assert.ErrorIs(t, err, repository.ErrGameSessionNotFound)
later(11 * time.Minute)
_, err = e.sessions.Complete(ctx, e.alice, id, models.GameSessionCompleteInput{Score: score(1)})
assert.ErrorIs(t, err, ErrGameSessionRejected)
assert.Equal(t, constants.GameSessionStatusStarted, e.session(id).Status, "left for the session job")
assert.Nil(t, e.session(id).CompletionFailedAt, "a refusal is not a system error")
})
t.Run("a game turned off during the play refunds the entry cost", func(t *testing.T) {
off := e.gameWith("turned-off", entities.GameResultRules{}, constants.GameRewardTypeFixed, `{"amount": 5}`, 0)
before := e.coinBalance(e.alice)
id := start(e.alice, off.ID, "a3")
_, err := e.admin.SetGameStatus(ctx, e.orgA, e.manager, off.ID, models.EnakGameStatusInput{Status: "INACTIVE"})
require.NoError(t, err)
out, err := e.sessions.Complete(ctx, e.alice, id, models.GameSessionCompleteInput{})
require.NoError(t, err)
assert.Equal(t, constants.GameSessionStatusRefunded, out.Status)
assert.Equal(t, constants.GameSessionRefundGameDeactivated, *out.RefundReason)
assert.EqualValues(t, before, out.CoinBalance)
assert.Equal(t, int64(1), e.count(`SELECT COUNT(*) FROM audit_logs WHERE entity_id = ? AND source = 'customer_api'`, id))
again, err := e.sessions.Complete(ctx, e.alice, id, models.GameSessionCompleteInput{})
require.NoError(t, err)
assert.Equal(t, out, again)
})
t.Run("a failure after the credit rolls everything back and marks the session", func(t *testing.T) {
broken := *e.sessions
broken.sessions = failingRewards{repository.NewGameSessionRepository(e.db)}
before := e.coinBalance(e.alice)
id := start(e.alice, scored.ID, "a4")
later(time.Minute)
_, err := broken.Complete(ctx, e.alice, id, models.GameSessionCompleteInput{Score: score(50)})
require.ErrorIs(t, err, assert.AnError)
s := e.session(id)
assert.Equal(t, constants.GameSessionStatusStarted, s.Status)
assert.Zero(t, s.RewardTotal)
assert.NotNil(t, s.CompletionFailedAt)
assert.Zero(t, e.count(`SELECT COUNT(*) FROM wallet_transactions WHERE type = 'GAME_REWARD' AND reference_id = ?`, id))
assert.EqualValues(t, before-2, e.coinBalance(e.alice))
// Once it expires, the session job refunds it as a system error.
later(11 * time.Minute)
refunded, _, err := e.sessions.ProcessDueSessions(ctx)
require.NoError(t, err)
assert.GreaterOrEqual(t, refunded, 1)
assert.Equal(t, constants.GameSessionRefundSystemError, *e.session(id).RefundReason)
assert.EqualValues(t, before, e.coinBalance(e.alice))
})
t.Run("PROBABILITY draws on complete and keeps the draw", func(t *testing.T) {
lucky := e.gameWith("lucky", entities.GameResultRules{}, constants.GameRewardTypeProbability,
`{"table": [{"weight": 1, "amount": 1000}, {"weight": 999, "amount": 0}]}`, 0)
id := start(e.bob, lucky.ID, "b-lucky")
e.sessions.rng = &fixedRNG{draws: []int64{0}}
defer func() { e.sessions.rng = CryptoRewardRNG{} }()
out, err := e.sessions.Complete(ctx, e.bob, id, models.GameSessionCompleteInput{})
require.NoError(t, err)
assert.EqualValues(t, 1000, out.RewardTotal)
var breakdown map[string]any
require.NoError(t, json.Unmarshal(e.session(id).RewardBreakdown, &breakdown))
assert.EqualValues(t, 0, breakdown["detail"].(map[string]any)["roll"])
})
t.Run("an outcome worth nothing completes without a ledger row", func(t *testing.T) {
outcomes := e.gameWith("outcomes", entities.GameResultRules{}, constants.GameRewardTypeOutcomeBased, `{"outcomes": {"WIN": 5, "FAIL": 0}}`, 0)
id := start(e.bob, outcomes.ID, "b-fail")
fail := "FAIL"
out, err := e.sessions.Complete(ctx, e.bob, id, models.GameSessionCompleteInput{Outcome: &fail})
require.NoError(t, err)
assert.Zero(t, out.RewardTotal)
assert.False(t, e.session(id).Flagged)
assert.Zero(t, e.count(`SELECT COUNT(*) FROM game_session_rewards WHERE session_id = ?`, id))
})
}
// EG-402
func TestGameSession_EconomyGuard(t *testing.T) {
e := newEnakGameEnv(t)
ctx := e.ctx()
settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(e.db), e.txm)
t.Cleanup(func() {
e.db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, e.orgA)
e.db.Exec(`DELETE FROM organization_settings WHERE organization_id = ?`, e.orgA)
})
setLimits := func(user, global int64) {
t.Helper()
s, err := settings.Organization(ctx, e.orgA)
require.NoError(t, err)
s.EnakGame = models.EnakGameLimitSettings{UserDailyLimit: user, GlobalDailyLimit: global}
_, _, err = settings.UpdateOrganization(ctx, e.orgA, e.manager, *s)
require.NoError(t, err)
}
counter := func(scope string, id uuid.UUID) int64 {
t.Helper()
got, err := repository.NewGameRewardCounterRepository(e.db).Get(ctx, e.orgA, scope, id, walletDay(e.now))
require.NoError(t, err)
return got
}
gameDaily := int64(1000)
thirty := e.gameWith("thirty", entities.GameResultRules{DailyRewardLimit: &gameDaily}, constants.GameRewardTypeFixed, `{"amount": 30}`, 0)
e.coins(e.alice, 100, nil)
e.coins(e.bob, 100, nil)
play := func(customer uuid.UUID, gameID uuid.UUID, key string) (*models.GameSessionCompletion, uuid.UUID) {
t.Helper()
s, err := e.sessions.Start(ctx, customer, gameID, key)
require.NoError(t, err)
out, err := e.sessions.Complete(ctx, customer, s.SessionID, models.GameSessionCompleteInput{})
require.NoError(t, err)
return out, s.SessionID
}
// No limits: every reward is counted all the same.
out, _ := play(e.alice, thirty.ID, "a0")
assert.EqualValues(t, 30, out.RewardTotal)
assert.Empty(t, out.LimitedBy)
assert.EqualValues(t, 30, counter(constants.GameRewardScopeUser, e.alice))
assert.EqualValues(t, 30, counter(constants.GameRewardScopeGlobal, e.orgA))
// Alice has 10 left of her 40: a reward of 30 pays 10 and says why.
setLimits(40, 0)
out, id := play(e.alice, thirty.ID, "a1")
assert.EqualValues(t, 10, out.RewardTotal)
assert.Equal(t, []string{"USER_DAILY"}, out.LimitedBy)
var breakdown gameRewardBreakdown
require.NoError(t, json.Unmarshal(e.session(id).RewardBreakdown, &breakdown))
assert.Equal(t, []gameGuardCut{{Limit: "USER_DAILY", Value: 40, Asked: 30, Allowed: 10}}, breakdown.Guard)
assert.EqualValues(t, 30, breakdown.Base, "the base stays what was earned")
assert.EqualValues(t, 10, breakdown.Total)
assert.Equal(t, int64(10), e.count(`SELECT amount FROM wallet_transactions WHERE type = 'GAME_REWARD' AND reference_id = ?`, id))
again, err := e.sessions.Complete(ctx, e.alice, id, models.GameSessionCompleteInput{})
require.NoError(t, err)
assert.Equal(t, out, again, "the limit is part of the repeated answer")
// Nothing left: the session completes with no reward and no ledger row.
out, id = play(e.alice, thirty.ID, "a2")
assert.Zero(t, out.RewardTotal)
assert.Equal(t, []string{"USER_DAILY"}, out.LimitedBy)
assert.Equal(t, constants.GameSessionStatusCompleted, e.session(id).Status)
assert.False(t, e.session(id).Flagged)
assert.Zero(t, e.count(`SELECT COUNT(*) FROM wallet_transactions WHERE type = 'GAME_REWARD' AND reference_id = ?`, id))
assert.EqualValues(t, 40, counter(constants.GameRewardScopeUser, e.alice))
// The organization's limit cuts Bob, and the counters before it give the
// difference back: every counter holds what was paid.
setLimits(0, 75)
out, _ = play(e.bob, thirty.ID, "b1")
assert.EqualValues(t, 30, out.RewardTotal)
out, _ = play(e.bob, thirty.ID, "b2")
assert.EqualValues(t, 5, out.RewardTotal, "70 of 75 already given today")
assert.Equal(t, []string{"GLOBAL_DAILY"}, out.LimitedBy)
assert.EqualValues(t, 35, counter(constants.GameRewardScopeUser, e.bob))
assert.EqualValues(t, 75, counter(constants.GameRewardScopeGame, thirty.ID))
assert.EqualValues(t, 75, counter(constants.GameRewardScopeGlobal, e.orgA))
// The game's own daily limit, and a new day starting over.
setLimits(0, 0)
small := int64(12)
capped := e.gameWith("capped", entities.GameResultRules{DailyRewardLimit: &small}, constants.GameRewardTypeFixed, `{"amount": 30}`, 0)
out, _ = play(e.bob, capped.ID, "b3")
assert.EqualValues(t, 12, out.RewardTotal)
assert.Equal(t, []string{"GAME_DAILY"}, out.LimitedBy)
e.now = e.now.Add(24 * time.Hour)
if e.count(`SELECT COUNT(*) FROM game_budgets WHERE organization_id = ? AND period_start <= ?::date AND period_end >= ?::date`,
e.orgA, walletDay(e.now).Format("2006-01-02"), walletDay(e.now).Format("2006-01-02")) == 0 {
e.globalBudget(e.orgA, e.now)
}
out, _ = play(e.bob, capped.ID, "b4")
assert.EqualValues(t, 12, out.RewardTotal, "the next day has its own count")
}