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, CustomerSessionFilter{CustomerID: f.customer, Limit: 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")) }