Files
apskel-pos-backend/internal/repository/enakgame_repository_test.go
T
efrilmandClaude Opus 5.5 798a36bd6c 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>
2026-10-07 20:53:14 +07:00

472 lines
20 KiB
Go

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