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") }