EnakGame phases 1-8 of docs/tasks-enakgame.md (EG-101 to EG-803), built on the existing EnakPoint/EnakCoin wallet (docs/rfc-enakgame.md). Foundation (phase 1) - Migrations 000103-000106: games extended with organization, slug, status, entry cost and result rules, old games archived (not deleted); budgets, versioned reward configs, sessions and session rewards; the ledger types GAME_SPEND_REFUND, GAME_REWARD and REWARD_REDEEM_REFUND; audit_logs. - AuditLogger writes in the caller's transaction only. - enakgame.limit.user_daily and global_daily organization settings. Games and sessions (phases 2-4) - Admin /marketing/enakgame: games, reward config versions (immutable but for status, one ACTIVE per game), budgets with non-overlapping global periods and a daily job opening the next month. - Customer /customer/enakgame: start (Idempotency-Key, entry cost and config frozen on the session), complete (result validation, reward engine, max_reward cap, daily limits via game_reward_counters, one GAME_REWARD per budget), automatic refunds for system errors and deactivated games, and a session job. - Reward engine: FIXED, SCORE_BASED, OUTCOME_BASED, PROBABILITY (crypto/rand), rounded down. Vouchers and budgets (phases 5-6) - Migration 000108 and 000107: vouchers, codes, redemptions, cost attribution; Economy Guard counters. - STATIC and CODE_POOL redemption in one transaction with the REDEEM PIN action; realized cost traced through the lots to the budget that paid the reward. - Budget metrics: realized cost, forecast, exposure and status. Migrations 000109-000110 add the wallet_lots indexes they need, built CONCURRENTLY. Events (phase 7) - Migration 000111: game events, each with its own EVENT budget. Event extras stack per PRD §16 defaults, with event and per-customer limits. External vouchers (phase 8) - VoucherProvider contract, two-step PENDING redemption and a recovery job, tested with a fake provider. No provider adapter is registered yet, so EXTERNAL vouchers stay out of the catalog. Not yet decided before release: reward rounding, event stacking, budget exhaustion policy and thresholds (RFC §19.2). Migrations 000103-000111 have not been run on any shared database. Also fixes a leftover PAYMENT filter in a wallet test and a data race in a test PIN fake. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
975 lines
47 KiB
Go
975 lines
47 KiB
Go
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")
|
|
}
|