2026-10-07 20:53:14 +07:00
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" )
2026-10-08 12:51:39 +07:00
page , err := e . sessions . ListSessions ( ctx , e . alice , models . GameSessionListQuery { Page : 1 , Limit : 10 })
2026-10-07 20:53:14 +07:00
require . NoError ( t , err )
assert . EqualValues ( t , 1 , page . Pagination . Total )
2026-10-08 12:51:39 +07:00
page , err = e . sessions . ListSessions ( ctx , e . bob , models . GameSessionListQuery { Page : 1 , Limit : 10 })
2026-10-07 20:53:14 +07:00
require . NoError ( t , err )
assert . Empty ( t , page . Data )
2026-10-08 12:51:39 +07:00
// After a reload, the game finds the play it was running by game and status.
other := e . playableGame ( e . orgA , "other" , 1 )
otherStarted , err := e . sessions . Start ( ctx , e . alice , other . ID , "k2" )
require . NoError ( t , err )
_ , err = e . sessions . Complete ( ctx , e . alice , otherStarted . SessionID , models . GameSessionCompleteInput {})
require . NoError ( t , err )
running , err := e . sessions . ListSessions ( ctx , e . alice , models . GameSessionListQuery { GameID : game . ID . String (), Status : "started" })
require . NoError ( t , err )
require . Len ( t , running . Data , 1 )
assert . Equal ( t , started . SessionID , running . Data [ 0 ]. ID )
running , err = e . sessions . ListSessions ( ctx , e . alice , models . GameSessionListQuery { GameID : other . ID . String (), Status : constants . GameSessionStatusStarted })
require . NoError ( t , err )
assert . Empty ( t , running . Data , "the other game's play is completed" )
all , err := e . sessions . ListSessions ( ctx , e . alice , models . GameSessionListQuery {})
require . NoError ( t , err )
assert . EqualValues ( t , 2 , all . Pagination . Total )
_ , err = e . sessions . ListSessions ( ctx , e . alice , models . GameSessionListQuery { Status : "PLAYING" })
assert . ErrorIs ( t , err , ErrGameSessionRejected )
_ , err = e . sessions . ListSessions ( ctx , e . alice , models . GameSessionListQuery { GameID : "runner" })
assert . ErrorIs ( t , err , ErrGameSessionRejected )
2026-10-07 20:53:14 +07:00
}
// 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" )
}