feat(enakgame): game sessions, rewards, vouchers, budgets and events
EnakGame phases 1-8 of docs/tasks-enakgame.md (EG-101 to EG-803), built on the existing EnakPoint/EnakCoin wallet (docs/rfc-enakgame.md). Foundation (phase 1) - Migrations 000103-000106: games extended with organization, slug, status, entry cost and result rules, old games archived (not deleted); budgets, versioned reward configs, sessions and session rewards; the ledger types GAME_SPEND_REFUND, GAME_REWARD and REWARD_REDEEM_REFUND; audit_logs. - AuditLogger writes in the caller's transaction only. - enakgame.limit.user_daily and global_daily organization settings. Games and sessions (phases 2-4) - Admin /marketing/enakgame: games, reward config versions (immutable but for status, one ACTIVE per game), budgets with non-overlapping global periods and a daily job opening the next month. - Customer /customer/enakgame: start (Idempotency-Key, entry cost and config frozen on the session), complete (result validation, reward engine, max_reward cap, daily limits via game_reward_counters, one GAME_REWARD per budget), automatic refunds for system errors and deactivated games, and a session job. - Reward engine: FIXED, SCORE_BASED, OUTCOME_BASED, PROBABILITY (crypto/rand), rounded down. Vouchers and budgets (phases 5-6) - Migration 000108 and 000107: vouchers, codes, redemptions, cost attribution; Economy Guard counters. - STATIC and CODE_POOL redemption in one transaction with the REDEEM PIN action; realized cost traced through the lots to the budget that paid the reward. - Budget metrics: realized cost, forecast, exposure and status. Migrations 000109-000110 add the wallet_lots indexes they need, built CONCURRENTLY. Events (phase 7) - Migration 000111: game events, each with its own EVENT budget. Event extras stack per PRD §16 defaults, with event and per-customer limits. External vouchers (phase 8) - VoucherProvider contract, two-step PENDING redemption and a recovery job, tested with a fake provider. No provider adapter is registered yet, so EXTERNAL vouchers stay out of the catalog. Not yet decided before release: reward rounding, event stacking, budget exhaustion policy and thresholds (RFC §19.2). Migrations 000103-000111 have not been run on any shared database. Also fixes a leftover PAYMENT filter in a wallet test and a data race in a test PIN fake. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2c9753fae7
commit
798a36bd6c
@@ -0,0 +1,471 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
)
|
||||
|
||||
// EnakGame tables against Postgres (docs/rfc-enakgame.md §5): what the constraints
|
||||
// refuse, and what the repositories do. Needs TEST_DATABASE_URL; see
|
||||
// wallet_repository_test.go.
|
||||
|
||||
type enakGameFixture struct {
|
||||
db *gorm.DB
|
||||
games EnakGameRepository
|
||||
sessions GameSessionRepository
|
||||
budgets GameBudgetRepository
|
||||
org uuid.UUID
|
||||
other uuid.UUID
|
||||
customer uuid.UUID
|
||||
admin uuid.UUID
|
||||
}
|
||||
|
||||
func newEnakGameFixture(t *testing.T) *enakGameFixture {
|
||||
t.Helper()
|
||||
db := walletTestDB(t)
|
||||
f := &enakGameFixture{
|
||||
db: db, games: NewEnakGameRepository(db), sessions: NewGameSessionRepository(db), budgets: NewGameBudgetRepository(db),
|
||||
org: uuid.New(), other: uuid.New(), customer: uuid.New(), admin: uuid.New(),
|
||||
}
|
||||
orgs := []uuid.UUID{f.org, f.other}
|
||||
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 (?, ?, 'enakgame test')`, f.customer, f.org).Error)
|
||||
t.Cleanup(func() {
|
||||
for _, q := range []string{
|
||||
`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 game_reward_configs WHERE organization_id IN ?`,
|
||||
`DELETE FROM game_budgets WHERE organization_id IN ?`,
|
||||
`DELETE FROM games WHERE organization_id IN ?`,
|
||||
`DELETE FROM wallet_transactions WHERE organization_id IN ?`,
|
||||
`DELETE FROM customers WHERE organization_id IN ?`,
|
||||
`DELETE FROM organizations WHERE id IN ?`,
|
||||
} {
|
||||
db.Exec(q, orgs)
|
||||
}
|
||||
})
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *enakGameFixture) game(t *testing.T, org uuid.UUID, slug string) *entities.Game {
|
||||
t.Helper()
|
||||
game := &entities.Game{
|
||||
Name: "Tap " + slug, Type: entities.GameTypeMinigame, OrganizationID: &org, Slug: &slug,
|
||||
Status: constants.GameStatusActive, EntryCost: 5, SessionTTLSeconds: 600,
|
||||
}
|
||||
require.NoError(t, f.games.CreateGame(context.Background(), game))
|
||||
return game
|
||||
}
|
||||
|
||||
func (f *enakGameFixture) config(t *testing.T, game *entities.Game) *entities.GameRewardConfig {
|
||||
t.Helper()
|
||||
config := &entities.GameRewardConfig{
|
||||
OrganizationID: *game.OrganizationID, GameID: game.ID, RewardType: constants.GameRewardTypeFixed,
|
||||
Rules: entities.JSONDocument(`{"amount": 10}`), MaxReward: 10, CreatedBy: f.admin,
|
||||
}
|
||||
require.NoError(t, f.games.CreateRewardConfig(context.Background(), config))
|
||||
return config
|
||||
}
|
||||
|
||||
// walletTx writes a bare ledger row for the session foreign keys to point at.
|
||||
// reverses is the row a refund reverses, nil otherwise.
|
||||
func (f *enakGameFixture) walletTx(t *testing.T, txType string, amount int64, reverses *uuid.UUID) uuid.UUID {
|
||||
t.Helper()
|
||||
id := uuid.New()
|
||||
require.NoError(t, f.db.Exec(`
|
||||
INSERT INTO wallet_transactions (id, organization_id, customer_id, currency, type, amount, balance_after,
|
||||
reference_type, reference_id, reverses_transaction_id, description)
|
||||
VALUES (?, ?, ?, 'COIN', ?, ?, 0, 'GAME_SESSION', ?, ?, 'test')`,
|
||||
id, f.org, f.customer, txType, amount, uuid.New(), reverses).Error)
|
||||
return id
|
||||
}
|
||||
|
||||
func (f *enakGameFixture) session(t *testing.T, game *entities.Game, config *entities.GameRewardConfig, expiresAt time.Time) *entities.GameSession {
|
||||
t.Helper()
|
||||
session := &entities.GameSession{
|
||||
OrganizationID: f.org, CustomerID: f.customer, GameID: game.ID, RewardConfigID: config.ID,
|
||||
EntryCost: game.EntryCost, ExpiresAt: expiresAt, SpendTransactionID: f.walletTx(t, constants.WalletTxTypeGameSpend, -game.EntryCost, nil),
|
||||
}
|
||||
require.NoError(t, f.sessions.CreateSession(context.Background(), session))
|
||||
return session
|
||||
}
|
||||
|
||||
func (f *enakGameFixture) budget(t *testing.T, org uuid.UUID, scope string, start, end time.Time) *entities.GameBudget {
|
||||
t.Helper()
|
||||
budget := &entities.GameBudget{
|
||||
OrganizationID: org, Scope: scope, Name: "Oktober", PeriodStart: start, PeriodEnd: end, Amount: 100_000_000, CreatedBy: f.admin,
|
||||
}
|
||||
require.NoError(t, f.budgets.CreateBudget(context.Background(), budget))
|
||||
return budget
|
||||
}
|
||||
|
||||
func date(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) }
|
||||
|
||||
func TestEnakGameSchema_RejectsWhatTheRFCForbids(t *testing.T) {
|
||||
f := newEnakGameFixture(t)
|
||||
game := f.game(t, f.org, "tap")
|
||||
config := f.config(t, game)
|
||||
ctx := context.Background()
|
||||
rejected := func(t *testing.T, err error, constraint string) {
|
||||
t.Helper()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), constraint)
|
||||
}
|
||||
|
||||
t.Run("ACTIVE game without organization", func(t *testing.T) {
|
||||
rejected(t, f.db.Exec(`INSERT INTO games (name, type, slug, entry_cost) VALUES ('x', 'MINIGAME', 'x', 1)`).Error, "chk_games_enakgame_identity")
|
||||
})
|
||||
t.Run("ACTIVE game without slug", func(t *testing.T) {
|
||||
rejected(t, f.db.Exec(`INSERT INTO games (name, type, organization_id, entry_cost) VALUES ('x', 'MINIGAME', ?, 1)`, f.org).Error, "chk_games_enakgame_identity")
|
||||
})
|
||||
t.Run("entry cost 0", func(t *testing.T) {
|
||||
rejected(t, f.db.Exec(`INSERT INTO games (name, type, organization_id, slug, entry_cost) VALUES ('x', 'MINIGAME', ?, 'free', 0)`, f.org).Error, "chk_games_entry_cost")
|
||||
})
|
||||
t.Run("no entry cost", func(t *testing.T) {
|
||||
rejected(t, f.db.Exec(`INSERT INTO games (name, type, organization_id, slug) VALUES ('x', 'MINIGAME', ?, 'none')`, f.org).Error, "entry_cost")
|
||||
})
|
||||
t.Run("same slug twice in an organization", func(t *testing.T) {
|
||||
rejected(t, f.db.Exec(`INSERT INTO games (name, type, organization_id, slug, entry_cost) VALUES ('x', 'MINIGAME', ?, 'tap', 1)`, f.org).Error, "uq_games_org_slug")
|
||||
f.game(t, f.other, "tap") // another organization may use it
|
||||
})
|
||||
|
||||
t.Run("two ACTIVE configs for a game", func(t *testing.T) {
|
||||
second := f.config(t, game)
|
||||
ok, err := f.games.SetRewardConfigStatus(ctx, f.org, config.ID, constants.GameRewardConfigStatusDraft, constants.GameRewardConfigStatusActive)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
_, err = f.games.SetRewardConfigStatus(ctx, f.org, second.ID, constants.GameRewardConfigStatusDraft, constants.GameRewardConfigStatusActive)
|
||||
rejected(t, err, "uq_game_reward_configs_active")
|
||||
})
|
||||
|
||||
t.Run("REFUNDED session without refund transaction", func(t *testing.T) {
|
||||
session := f.session(t, game, config, time.Now().Add(time.Minute))
|
||||
rejected(t, f.db.Exec(`UPDATE game_sessions SET status = 'REFUNDED', refund_reason = 'SYSTEM_ERROR' WHERE id = ?`, session.ID).Error, "chk_game_sessions_refund")
|
||||
rejected(t, f.db.Exec(`UPDATE game_sessions SET refund_transaction_id = spend_transaction_id, refund_reason = 'SYSTEM_ERROR' WHERE id = ?`, session.ID).Error, "chk_game_sessions_refund")
|
||||
})
|
||||
|
||||
t.Run("two GLOBAL budgets with the same start", func(t *testing.T) {
|
||||
f.budget(t, f.org, constants.GameBudgetScopeGlobal, date(2026, 10, 1), date(2026, 10, 31))
|
||||
err := f.budgets.CreateBudget(ctx, &entities.GameBudget{
|
||||
OrganizationID: f.org, Scope: constants.GameBudgetScopeGlobal, Name: "x",
|
||||
PeriodStart: date(2026, 10, 1), PeriodEnd: date(2026, 10, 15), Amount: 1, CreatedBy: f.admin,
|
||||
})
|
||||
assert.ErrorIs(t, err, ErrGameBudgetPeriodTaken, "uq_game_budgets_global_period")
|
||||
f.budget(t, f.org, constants.GameBudgetScopeEvent, date(2026, 10, 1), date(2026, 10, 7))
|
||||
f.budget(t, f.other, constants.GameBudgetScopeGlobal, date(2026, 10, 1), date(2026, 10, 31))
|
||||
})
|
||||
|
||||
t.Run("same reward ledger row twice", func(t *testing.T) {
|
||||
session := f.session(t, game, config, time.Now().Add(time.Minute))
|
||||
global := f.budget(t, f.org, constants.GameBudgetScopeGlobal, date(2026, 11, 1), date(2026, 11, 30))
|
||||
event := f.budget(t, f.org, constants.GameBudgetScopeEvent, date(2026, 11, 1), date(2026, 11, 7))
|
||||
reward := f.walletTx(t, constants.WalletTxTypeGameReward, 10, nil)
|
||||
err := f.sessions.CreateSessionRewards(ctx, []entities.GameSessionReward{
|
||||
{SessionID: session.ID, BudgetID: global.ID, Amount: 10, WalletTransactionID: reward},
|
||||
{SessionID: session.ID, BudgetID: event.ID, Amount: 10, WalletTransactionID: reward},
|
||||
})
|
||||
rejected(t, err, "uq_game_session_rewards_wallet_transaction")
|
||||
})
|
||||
}
|
||||
|
||||
// D4: of two moves out of STARTED at once, exactly one gets the row.
|
||||
func TestGameSessionRepository_ConcurrentTransitionsOnlyOneWins(t *testing.T) {
|
||||
f := newEnakGameFixture(t)
|
||||
game := f.game(t, f.org, "race")
|
||||
config := f.config(t, game)
|
||||
txm := NewTxManager(f.db)
|
||||
|
||||
for round := 0; round < 5; round++ {
|
||||
session := f.session(t, game, config, time.Now().Add(time.Minute))
|
||||
refund := f.walletTx(t, constants.WalletTxTypeGameSpendRefund, game.EntryCost, &session.SpendTransactionID)
|
||||
moves := []func(ctx context.Context) (bool, error){
|
||||
func(ctx context.Context) (bool, error) {
|
||||
return f.sessions.CompleteSession(ctx, session.ID, GameSessionCompletion{
|
||||
Result: entities.JSONDocument(`{"score": 10}`), RewardTotal: 10, EndedAt: time.Now(),
|
||||
})
|
||||
},
|
||||
func(ctx context.Context) (bool, error) {
|
||||
return f.sessions.RefundSession(ctx, session.ID, refund, constants.GameSessionRefundSystemError, time.Now())
|
||||
},
|
||||
func(ctx context.Context) (bool, error) { return f.sessions.ExpireSession(ctx, session.ID, time.Now()) },
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
won := 0
|
||||
start := make(chan struct{})
|
||||
for _, move := range moves {
|
||||
move := move
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
err := txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
ok, err := move(ctx)
|
||||
if ok {
|
||||
mu.Lock()
|
||||
won++
|
||||
mu.Unlock()
|
||||
}
|
||||
return err
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
assert.Equal(t, 1, won, "round %d", round)
|
||||
|
||||
got, err := f.sessions.GetSession(context.Background(), f.org, session.ID)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, constants.GameSessionStatusStarted, got.Status)
|
||||
assert.NotNil(t, got.EndedAt)
|
||||
ok, err := f.sessions.MarkCompletionFailed(context.Background(), session.ID, time.Now())
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok, "a session that left STARTED is not marked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameSessionRepository_ReadsAndMoves(t *testing.T) {
|
||||
f := newEnakGameFixture(t)
|
||||
game := f.game(t, f.org, "tap")
|
||||
config := f.config(t, game)
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
|
||||
expired := f.session(t, game, config, now.Add(-time.Minute))
|
||||
open := f.session(t, game, config, now.Add(time.Hour))
|
||||
|
||||
got, err := f.sessions.GetCustomerSession(ctx, f.customer, open.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, constants.GameSessionStatusStarted, got.Status)
|
||||
assert.Equal(t, game.EntryCost, got.EntryCost)
|
||||
assert.Equal(t, config.ID, got.RewardConfigID)
|
||||
_, err = f.sessions.GetCustomerSession(ctx, uuid.New(), open.ID)
|
||||
assert.ErrorIs(t, err, ErrGameSessionNotFound, "another customer's session")
|
||||
_, err = f.sessions.GetSession(ctx, f.other, open.ID)
|
||||
assert.ErrorIs(t, err, ErrGameSessionNotFound, "another organization's session")
|
||||
bySpend, err := f.sessions.GetSessionBySpendTransaction(ctx, open.SpendTransactionID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, open.ID, bySpend.ID)
|
||||
|
||||
due, err := f.sessions.ListExpiredSessions(ctx, now, 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, due, 1)
|
||||
assert.Equal(t, expired.ID, due[0].ID)
|
||||
byGame, err := f.sessions.ListOpenSessionsByGame(ctx, f.org, game.ID, 10)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, byGame, 2)
|
||||
|
||||
marked, err := f.sessions.MarkCompletionFailed(ctx, open.ID, now)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, marked)
|
||||
ok, err := f.sessions.CompleteSession(ctx, open.ID, GameSessionCompletion{
|
||||
Result: entities.JSONDocument(`{"score": 120}`), RewardBreakdown: entities.JSONDocument(`{"base": 10}`),
|
||||
RewardTotal: 10, Flagged: true, EndedAt: now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
got, err = f.sessions.GetSession(ctx, f.org, open.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, constants.GameSessionStatusCompleted, got.Status)
|
||||
assert.JSONEq(t, `{"score": 120}`, string(got.Result))
|
||||
assert.JSONEq(t, `{"base": 10}`, string(got.RewardBreakdown))
|
||||
assert.EqualValues(t, 10, got.RewardTotal)
|
||||
assert.True(t, got.Flagged)
|
||||
assert.NotNil(t, got.CompletionFailedAt)
|
||||
|
||||
sessions, total, err := f.sessions.ListCustomerSessions(ctx, f.customer, 0, 1)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 2, total)
|
||||
assert.Len(t, sessions, 1)
|
||||
|
||||
budget := f.budget(t, f.org, constants.GameBudgetScopeGlobal, date(2026, 10, 1), date(2026, 10, 31))
|
||||
reward := f.walletTx(t, constants.WalletTxTypeGameReward, 10, nil)
|
||||
require.NoError(t, f.sessions.CreateSessionRewards(ctx, []entities.GameSessionReward{
|
||||
{SessionID: open.ID, BudgetID: budget.ID, Amount: 10, WalletTransactionID: reward},
|
||||
}))
|
||||
rewards, err := f.sessions.ListSessionRewards(ctx, open.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []entities.GameSessionReward{{SessionID: open.ID, BudgetID: budget.ID, Amount: 10, WalletTransactionID: reward}}, rewards)
|
||||
}
|
||||
|
||||
func TestEnakGameRepository_GamesAndRewardConfigs(t *testing.T) {
|
||||
f := newEnakGameFixture(t)
|
||||
ctx := context.Background()
|
||||
game := f.game(t, f.org, "tap")
|
||||
theirs := f.game(t, f.other, "theirs")
|
||||
max := int64(1000)
|
||||
game.ResultRules = entities.GameResultRules{MaxScore: &max, Outcomes: []string{"WIN", "LOSE"}}
|
||||
game.EntryCost = 7
|
||||
require.NoError(t, f.games.UpdateGame(ctx, game))
|
||||
|
||||
got, err := f.games.GetGame(ctx, f.org, game.ID)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 7, got.EntryCost)
|
||||
assert.Equal(t, game.ResultRules, got.ResultRules)
|
||||
_, err = f.games.GetGame(ctx, f.org, theirs.ID)
|
||||
assert.ErrorIs(t, err, ErrEnakGameNotFound, "another organization's game")
|
||||
theirs.OrganizationID = &f.org
|
||||
assert.ErrorIs(t, f.games.UpdateGame(ctx, theirs), ErrEnakGameNotFound)
|
||||
assert.ErrorIs(t, f.games.SetGameStatus(ctx, f.org, theirs.ID, constants.GameStatusInactive), ErrEnakGameNotFound)
|
||||
|
||||
games, total, err := f.games.ListGames(ctx, EnakGameFilter{OrganizationID: f.org, Limit: 10})
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 1, total, "only the organization's games; the archived old ones have none")
|
||||
assert.Equal(t, game.ID, games[0].ID)
|
||||
|
||||
require.NoError(t, f.games.SetGameStatus(ctx, f.org, game.ID, constants.GameStatusArchived))
|
||||
assert.ErrorIs(t, f.games.SetGameStatus(ctx, f.org, game.ID, constants.GameStatusActive), ErrEnakGameNotFound, "an archived game never changes")
|
||||
assert.ErrorIs(t, f.games.UpdateGame(ctx, game), ErrEnakGameNotFound)
|
||||
games, _, err = f.games.ListGames(ctx, EnakGameFilter{OrganizationID: f.org, Statuses: []string{constants.GameStatusActive}, Limit: 10})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, games)
|
||||
|
||||
// Reward configs: versions count up per game, only status ever changes.
|
||||
other := f.game(t, f.org, "other")
|
||||
v1, v2 := f.config(t, other), f.config(t, other)
|
||||
assert.Equal(t, 1, v1.Version)
|
||||
assert.Equal(t, 2, v2.Version)
|
||||
assert.Equal(t, 1, f.config(t, game).Version, "per game")
|
||||
err = f.games.CreateRewardConfig(ctx, &entities.GameRewardConfig{
|
||||
OrganizationID: f.other, GameID: other.ID, RewardType: constants.GameRewardTypeFixed,
|
||||
Rules: entities.JSONDocument(`{}`), CreatedBy: f.admin,
|
||||
})
|
||||
assert.ErrorIs(t, err, ErrEnakGameNotFound, "a config for another organization's game")
|
||||
|
||||
_, err = f.games.GetActiveRewardConfig(ctx, f.org, other.ID)
|
||||
assert.ErrorIs(t, err, ErrGameRewardConfigNotFound)
|
||||
ok, err := f.games.SetRewardConfigStatus(ctx, f.org, v2.ID, constants.GameRewardConfigStatusDraft, constants.GameRewardConfigStatusActive)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
ok, err = f.games.SetRewardConfigStatus(ctx, f.org, v2.ID, constants.GameRewardConfigStatusDraft, constants.GameRewardConfigStatusActive)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok, "no longer DRAFT")
|
||||
ok, err = f.games.SetRewardConfigStatus(ctx, f.other, v1.ID, constants.GameRewardConfigStatusDraft, constants.GameRewardConfigStatusRetired)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok, "another organization")
|
||||
|
||||
active, err := f.games.GetActiveRewardConfig(ctx, f.org, other.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, v2.ID, active.ID)
|
||||
assert.JSONEq(t, `{"amount": 10}`, string(active.Rules))
|
||||
configs, err := f.games.ListRewardConfigs(ctx, f.org, other.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, configs, 2)
|
||||
assert.Equal(t, []int{2, 1}, []int{configs[0].Version, configs[1].Version})
|
||||
_, err = f.games.GetRewardConfig(ctx, f.other, v1.ID)
|
||||
assert.ErrorIs(t, err, ErrGameRewardConfigNotFound)
|
||||
|
||||
// LockGame takes the row lock: a second locker waits for the first transaction.
|
||||
txm := NewTxManager(f.db)
|
||||
locked := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
go func() {
|
||||
_ = txm.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
_, err := f.games.LockGame(ctx, f.org, other.ID)
|
||||
assert.NoError(t, err)
|
||||
close(locked)
|
||||
<-release
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
<-locked
|
||||
acquired := make(chan struct{})
|
||||
go func() {
|
||||
_ = txm.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
_, err := f.games.LockGame(ctx, f.org, other.ID)
|
||||
assert.NoError(t, err)
|
||||
return nil
|
||||
})
|
||||
close(acquired)
|
||||
}()
|
||||
select {
|
||||
case <-acquired:
|
||||
t.Fatal("the second lock did not wait")
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
close(release)
|
||||
<-acquired
|
||||
}
|
||||
|
||||
func TestGameBudgetRepository(t *testing.T) {
|
||||
f := newEnakGameFixture(t)
|
||||
ctx := context.Background()
|
||||
// A late evening in Jakarta is still the previous day in UTC: the date is taken as
|
||||
// given, not converted.
|
||||
jakarta := time.FixedZone("WIB", 7*3600)
|
||||
october := f.budget(t, f.org, constants.GameBudgetScopeGlobal,
|
||||
time.Date(2026, 10, 1, 0, 30, 0, 0, jakarta), time.Date(2026, 10, 31, 23, 30, 0, 0, jakarta))
|
||||
f.budget(t, f.org, constants.GameBudgetScopeGlobal, date(2026, 11, 1), date(2026, 11, 30))
|
||||
event := f.budget(t, f.org, constants.GameBudgetScopeEvent, date(2026, 10, 10), date(2026, 10, 20))
|
||||
f.budget(t, f.other, constants.GameBudgetScopeGlobal, date(2026, 10, 1), date(2026, 10, 31))
|
||||
|
||||
got, err := f.budgets.GetBudget(ctx, f.org, october.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "2026-10-01", got.PeriodStart.Format("2006-01-02"))
|
||||
assert.Equal(t, "2026-10-31", got.PeriodEnd.Format("2006-01-02"))
|
||||
assert.JSONEq(t, `{}`, string(got.Thresholds))
|
||||
_, err = f.budgets.GetBudget(ctx, f.other, october.ID)
|
||||
assert.ErrorIs(t, err, ErrGameBudgetNotFound)
|
||||
|
||||
for day, want := range map[time.Time]uuid.UUID{
|
||||
date(2026, 10, 1): october.ID,
|
||||
date(2026, 10, 15): october.ID,
|
||||
date(2026, 10, 31): october.ID,
|
||||
} {
|
||||
got, err := f.budgets.GetGlobalBudgetOn(ctx, f.org, day)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, got.ID, day)
|
||||
}
|
||||
_, err = f.budgets.GetGlobalBudgetOn(ctx, f.org, date(2026, 9, 30))
|
||||
assert.ErrorIs(t, err, ErrGameBudgetNotFound)
|
||||
|
||||
event.Amount = 5_000_000
|
||||
event.Thresholds = entities.JSONDocument(`{"warning": 70, "critical": 90}`)
|
||||
require.NoError(t, f.budgets.UpdateBudget(ctx, event))
|
||||
got, err = f.budgets.GetBudget(ctx, f.org, event.ID)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 5_000_000, got.Amount)
|
||||
assert.JSONEq(t, `{"warning": 70, "critical": 90}`, string(got.Thresholds))
|
||||
event.OrganizationID = f.other
|
||||
assert.ErrorIs(t, f.budgets.UpdateBudget(ctx, event), ErrGameBudgetNotFound)
|
||||
|
||||
budgets, total, err := f.budgets.ListBudgets(ctx, GameBudgetFilter{OrganizationID: f.org, Scope: constants.GameBudgetScopeGlobal, Limit: 10})
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 2, total)
|
||||
assert.Equal(t, "2026-11-01", budgets[0].PeriodStart.Format("2006-01-02"), "latest period first")
|
||||
}
|
||||
|
||||
// EG-701: what game_events refuses.
|
||||
func TestGameEventSchema_RejectsWhatTheRFCForbids(t *testing.T) {
|
||||
f := newEnakGameFixture(t)
|
||||
event := f.budget(t, f.org, constants.GameBudgetScopeEvent, date(2026, 10, 1), date(2026, 10, 7))
|
||||
t.Cleanup(func() { f.db.Exec(`DELETE FROM game_events WHERE organization_id = ?`, f.org) })
|
||||
insert := func(budget any, start, end string) error {
|
||||
return f.db.Exec(`INSERT INTO game_events (organization_id, name, slug, start_at, end_at, budget_id)
|
||||
VALUES (?, 'e', ?, ?, ?, ?)`, f.org, uuid.NewString(), start, end, budget).Error
|
||||
}
|
||||
err := insert(nil, "2026-10-01T00:00:00Z", "2026-10-02T00:00:00Z")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "budget_id")
|
||||
err = insert(event.ID, "2026-10-02T00:00:00Z", "2026-10-02T00:00:00Z")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "chk_game_events_period")
|
||||
err = insert(event.ID, "2026-10-02T00:00:00Z", "2026-10-01T00:00:00Z")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "chk_game_events_period")
|
||||
require.NoError(t, insert(event.ID, "2026-10-01T00:00:00Z", "2026-10-02T00:00:00Z"))
|
||||
}
|
||||
Reference in New Issue
Block a user