package processor import ( "sync" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" ) // EnakGame events against Postgres (docs/rfc-enakgame.md §5.5, §7.2 step 7). Needs // TEST_DATABASE_URL; see internal/repository/wallet_repository_test.go. func (e *enakGameEnv) eventBudget(org uuid.UUID, name string) *models.GameBudget { e.t.Helper() b, err := e.budgets.CreateBudget(e.ctx(), org, e.manager, models.GameBudgetInput{ Scope: "EVENT", Name: name, PeriodStart: "2026-01-01", PeriodEnd: "2026-12-31", Amount: 10_000_000, }) require.NoError(e.t, err) return b } func (e *enakGameEnv) event(in models.GameEventInput) *models.GameEvent { e.t.Helper() if in.Name == "" { in.Name = "Event" } if in.Slug == "" { in.Slug = "event-" + uuid.NewString()[:8] } if in.StartAt.IsZero() { in.StartAt, in.EndAt = e.now.Add(-time.Hour), e.now.Add(24*time.Hour) } if in.Status == "" { in.Status = constants.GameEventStatusActive } ev, err := e.events.CreateEvent(e.ctx(), e.orgA, e.manager, in) require.NoError(e.t, err) return ev } // EG-702 func TestGameEventAdmin(t *testing.T) { e := newEnakGameEnv(t) ctx := e.ctx() game := e.playableGame(e.orgA, "runner", 1) theirGame := e.playableGame(e.orgB, "theirs", 1) budget := e.eventBudget(e.orgA, "Ramadan") theirBudget := e.eventBudget(e.orgB, "Theirs") 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) valid := func() models.GameEventInput { return models.GameEventInput{Name: "Ramadan", Slug: "ramadan", StartAt: e.now, EndAt: e.now.Add(time.Hour), Multiplier: ptr(2.0), BudgetID: budget.ID, GameIDs: []uuid.UUID{game.ID}} } for name, mutate := range map[string]func(*models.GameEventInput){ "global budget": func(in *models.GameEventInput) { in.BudgetID = uuid.MustParse(global) }, "another org's budget": func(in *models.GameEventInput) { in.BudgetID = theirBudget.ID }, "another org's game": func(in *models.GameEventInput) { in.GameIDs = []uuid.UUID{theirGame.ID} }, "no games": func(in *models.GameEventInput) { in.GameIDs = nil }, "ends before start": func(in *models.GameEventInput) { in.EndAt = in.StartAt }, "nothing to add": func(in *models.GameEventInput) { in.Multiplier = ptr(1.0) }, "multiplier below 1": func(in *models.GameEventInput) { in.Multiplier = ptr(0.5) }, "three decimals": func(in *models.GameEventInput) { in.Multiplier = ptr(1.125) }, "zero bonus": func(in *models.GameEventInput) { in.Bonus = ptr(int64(0)) }, "unknown timezone": func(in *models.GameEventInput) { in.Timezone = "Mars/Olympus" }, "ended on create": func(in *models.GameEventInput) { in.Status = "ENDED" }, } { in := valid() mutate(&in) _, err := e.events.CreateEvent(ctx, e.orgA, e.manager, in) assert.ErrorIs(t, err, ErrEnakGameRejected, name) } ev, err := e.events.CreateEvent(ctx, e.orgA, e.manager, valid()) require.NoError(t, err) assert.Equal(t, constants.GameEventStatusDraft, ev.Status) assert.Equal(t, "Asia/Jakarta", ev.Timezone) assert.Equal(t, []uuid.UUID{game.ID}, ev.GameIDs) _, err = e.events.CreateEvent(ctx, e.orgA, e.manager, valid()) assert.ErrorIs(t, err, ErrEnakGameRejected, "slug in use") _, err = e.events.GetEvent(ctx, e.orgB, ev.ID) assert.ErrorIs(t, err, repository.ErrGameEventNotFound) in := GameEventInputFrom(ev) in.Bonus = ptr(int64(5)) updated, err := e.events.UpdateEvent(ctx, e.orgA, e.manager, ev.ID, in) require.NoError(t, err) assert.EqualValues(t, 5, *updated.Bonus) assert.EqualValues(t, 2, *updated.Multiplier) for _, step := range []struct { to string ok bool }{{"ENDED", false}, {"ACTIVE", true}, {"DRAFT", false}, {"ENDED", true}, {"ACTIVE", false}, {"CANCELLED", false}} { _, err := e.events.SetEventStatus(ctx, e.orgA, e.manager, ev.ID, models.GameEventStatusInput{Status: step.to}) if step.ok { assert.NoError(t, err, step.to) } else { assert.ErrorIs(t, err, ErrEnakGameRejected, step.to) } } _, err = e.events.UpdateEvent(ctx, e.orgA, e.manager, ev.ID, in) assert.ErrorIs(t, err, ErrEnakGameRejected, "an ended event never changes") assert.Equal(t, []string{"CREATED", "UPDATED", "STATUS_CHANGED", "STATUS_CHANGED"}, e.auditActions(constants.AuditEntityGameEvent, ev.ID)) // A budget an event points at cannot be deleted. assert.ErrorIs(t, e.budgets.DeleteBudget(ctx, e.orgA, e.manager, budget.ID), ErrEnakGameRejected) } // EG-703, EG-704 func TestGameEvent_RewardsAndLimits(t *testing.T) { e := newEnakGameEnv(t) ctx := e.ctx() ten := e.gameWith("ten", entities.GameResultRules{}, constants.GameRewardTypeFixed, `{"amount": 10}`, 0) other := e.gameWith("other", entities.GameResultRules{}, constants.GameRewardTypeFixed, `{"amount": 10}`, 0) e.coins(e.alice, 100, nil) e.coins(e.bob, 100, nil) 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) play := func(customer, gameID uuid.UUID) (*models.GameSessionCompletion, uuid.UUID) { t.Helper() s, err := e.sessions.Start(ctx, customer, gameID, uuid.NewString()) require.NoError(t, err) out, err := e.sessions.Complete(ctx, customer, s.SessionID, models.GameSessionCompleteInput{}) require.NoError(t, err) return out, s.SessionID } rewards := func(session uuid.UUID) map[string]int64 { t.Helper() var rows []struct { BudgetID string Amount int64 Ledger int64 } require.NoError(t, e.db.Raw(`SELECT r.budget_id::text AS budget_id, r.amount, t.amount AS ledger FROM game_session_rewards r JOIN wallet_transactions t ON t.id = r.wallet_transaction_id WHERE r.session_id = ? AND t.type = 'GAME_REWARD'`, session).Scan(&rows).Error) out := map[string]int64{} for _, row := range rows { assert.Equal(t, row.Amount, row.Ledger) out[row.BudgetID] = row.Amount } return out } // PRD §7: 10 Coin with Ramadan 2x is two GAME_REWARD rows, 10 from each budget. ramadanBudget := e.eventBudget(e.orgA, "Ramadan") ramadan := e.event(models.GameEventInput{Name: "Ramadan", Multiplier: ptr(2.0), BudgetID: ramadanBudget.ID, GameIDs: []uuid.UUID{ten.ID}, Priority: 10}) out, id := play(e.alice, ten.ID) assert.EqualValues(t, 20, out.RewardTotal) assert.Equal(t, models.GameSessionRewardParts{Base: 10, Event: 10}, out.Reward) assert.Equal(t, map[string]int64{global: 10, ramadanBudget.ID.String(): 10}, rewards(id)) // The other game is not in the event. out, id = play(e.alice, other.ID) assert.EqualValues(t, 10, out.RewardTotal) assert.Equal(t, map[string]int64{global: 10}, rewards(id)) // Events that are not running change nothing: not started, over, a draft. later := e.eventBudget(e.orgA, "Later") e.event(models.GameEventInput{Bonus: ptr(int64(50)), BudgetID: later.ID, GameIDs: []uuid.UUID{other.ID}, StartAt: e.now.Add(time.Hour), EndAt: e.now.Add(2 * time.Hour)}) e.event(models.GameEventInput{Bonus: ptr(int64(50)), BudgetID: later.ID, GameIDs: []uuid.UUID{other.ID}, StartAt: e.now.Add(-2 * time.Hour), EndAt: e.now.Add(-time.Minute)}) e.event(models.GameEventInput{Bonus: ptr(int64(50)), BudgetID: later.ID, GameIDs: []uuid.UUID{other.ID}, Status: "DRAFT"}) out, _ = play(e.alice, other.ID) assert.EqualValues(t, 10, out.RewardTotal) // The customer list shows the event on its game only. games, err := e.sessions.ListGames(ctx, e.alice) require.NoError(t, err) for _, g := range games { if g.ID == ten.ID { require.Len(t, g.Events, 1) assert.Equal(t, "Ramadan", g.Events[0].Name) assert.EqualValues(t, 2, *g.Events[0].Multiplier) assert.Equal(t, ramadan.EndAt.Unix(), g.Events[0].EndAt.Unix()) } else { assert.Empty(t, g.Events, g.Slug) } } // A second event on the same game: its bonus is counted on its own; its limit over // the whole event cuts it, then a customer's daily limit within it. bonusBudget := e.eventBudget(e.orgA, "Bonus") bonus := e.event(models.GameEventInput{Name: "Bonus", Bonus: ptr(int64(5)), BudgetID: bonusBudget.ID, GameIDs: []uuid.UUID{ten.ID}, RewardLimit: ptr(int64(12)), UserDailyLimit: ptr(int64(7))}) out, id = play(e.bob, ten.ID) assert.EqualValues(t, 25, out.RewardTotal, "10 + 10 + 5") assert.Equal(t, map[string]int64{global: 10, ramadanBudget.ID.String(): 10, bonusBudget.ID.String(): 5}, rewards(id)) out, _ = play(e.bob, ten.ID) assert.EqualValues(t, 22, out.RewardTotal, "Bob has 2 left of his 7 today") assert.Equal(t, []string{"EVENT_USER_DAILY"}, out.LimitedBy) out, _ = play(e.alice, ten.ID) assert.EqualValues(t, 25, out.RewardTotal, "Alice has her own 7") out, _ = play(e.alice, ten.ID) assert.EqualValues(t, 20, out.RewardTotal, "the event's 12 are given out: 5 + 2 + 5") assert.Equal(t, []string{"EVENT_LIMIT"}, out.LimitedBy) counter := repository.NewGameRewardCounterRepository(e.db) got, err := counter.Get(ctx, e.orgA, constants.GameRewardScopeEvent, bonus.ID, eventLifetimeDay) require.NoError(t, err) assert.EqualValues(t, 12, got) // The daily limit of the customer cuts events before the base, and the event // counters give back what was cut. carla := e.newCustomer(e.orgA) e.coins(carla, 10, nil) 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) }) s, err := settings.Organization(ctx, e.orgA) require.NoError(t, err) s.EnakGame.UserDailyLimit = 14 _, _, err = settings.UpdateOrganization(ctx, e.orgA, e.manager, *s) require.NoError(t, err) before, err := counter.Get(ctx, e.orgA, constants.GameRewardScopeEvent, ramadan.ID, eventLifetimeDay) require.NoError(t, err) out, id = play(carla, ten.ID) assert.EqualValues(t, 14, out.RewardTotal) assert.Equal(t, models.GameSessionRewardParts{Base: 10, Event: 4}, out.Reward, "the base first, the event gets what is left") assert.Equal(t, []string{"EVENT_LIMIT", "USER_DAILY"}, out.LimitedBy, "Bonus is used up; then her daily 14") assert.Equal(t, map[string]int64{global: 10, ramadanBudget.ID.String(): 4}, rewards(id)) after, err := counter.Get(ctx, e.orgA, constants.GameRewardScopeEvent, ramadan.ID, eventLifetimeDay) require.NoError(t, err) assert.EqualValues(t, 4, after-before, "the event counter holds what was paid") // D5: EnakPoint from the event's part of a reward cost the event's budget when // redeemed, the base part the global budget. s.EnakGame.UserDailyLimit = 0 _, _, err = settings.UpdateOrganization(ctx, e.orgA, e.manager, *s) require.NoError(t, err) dina := e.newCustomer(e.orgA) e.coins(dina, 2, nil) _, id = play(dina, ten.ID) assert.Equal(t, map[string]int64{global: 10, ramadanBudget.ID.String(): 10}, rewards(id), "Bonus is used up") e.exchangeCoins(dina, 20) voucher := e.staticVoucher(5, 2_000, 20) res, err := e.redeemVoucher(dina, voucher.ID, "event-cost") require.NoError(t, err) r := ramadanBudget.ID.String() assert.ElementsMatch(t, []costRow{ {BudgetID: &global, SourceType: "GAME_REWARD", Points: 10, Cost: 1_000}, {BudgetID: &r, SourceType: "GAME_REWARD", Points: 10, Cost: 1_000}, }, e.costs(res.ID)) } // Customers completing at once never take an event past its limit. func TestGameEvent_LimitHoldsUnderConcurrency(t *testing.T) { e := newEnakGameEnv(t) ctx := e.ctx() game := e.gameWith("rush", entities.GameResultRules{}, constants.GameRewardTypeFixed, `{"amount": 10}`, 0) budget := e.eventBudget(e.orgA, "Rush") e.event(models.GameEventInput{Bonus: ptr(int64(5)), BudgetID: budget.ID, GameIDs: []uuid.UUID{game.ID}, RewardLimit: ptr(int64(12))}) sessions := map[uuid.UUID]uuid.UUID{} for i := 0; i < 6; i++ { c := e.newCustomer(e.orgA) e.coins(c, 2, nil) s, err := e.sessions.Start(ctx, c, game.ID, "rush") require.NoError(t, err) sessions[c] = s.SessionID } var wg sync.WaitGroup begin := make(chan struct{}) for c, id := range sessions { c, id := c, id wg.Add(1) go func() { defer wg.Done() <-begin _, err := e.sessions.Complete(ctx, c, id, models.GameSessionCompleteInput{}) assert.NoError(t, err) }() } close(begin) wg.Wait() assert.Equal(t, int64(12), e.count(`SELECT COALESCE(SUM(amount), 0) FROM game_session_rewards WHERE budget_id = ?`, budget.ID)) assert.Equal(t, int64(60), e.count(`SELECT COALESCE(SUM(r.amount), 0) FROM game_session_rewards r JOIN game_budgets b ON b.id = r.budget_id WHERE b.scope = 'GLOBAL' AND r.session_id IN (SELECT id FROM game_sessions WHERE game_id = ?)`, game.ID), "every base paid") }