Files
apskel-pos-backend/internal/processor/game_event_db_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

290 lines
13 KiB
Go

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