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>
111 lines
3.9 KiB
Go
111 lines
3.9 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"apskel-pos-be/internal/constants"
|
|
)
|
|
|
|
// The Economy Guard counters against Postgres (docs/rfc-enakgame.md §9). Needs
|
|
// TEST_DATABASE_URL; see wallet_repository_test.go.
|
|
func TestGameRewardCounterRepository_ConcurrentConsumeNeverPassesTheLimit(t *testing.T) {
|
|
db := walletTestDB(t)
|
|
org, game := uuid.New(), uuid.New()
|
|
t.Cleanup(func() { db.Exec(`DELETE FROM game_reward_counters WHERE organization_id = ?`, org) })
|
|
repo := NewGameRewardCounterRepository(db)
|
|
txm := NewTxManager(db)
|
|
day := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC)
|
|
|
|
for _, round := range []struct {
|
|
each int64
|
|
wantTotal int64
|
|
}{{10, 100}, {7, 100}} {
|
|
var granted atomic.Int64
|
|
var wg sync.WaitGroup
|
|
begin := make(chan struct{})
|
|
for i := 0; i < 20; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
<-begin
|
|
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
|
got, err := repo.Consume(ctx, org, constants.GameRewardScopeGame, game, day, round.each, 100)
|
|
granted.Add(got)
|
|
return err
|
|
}))
|
|
}()
|
|
}
|
|
close(begin)
|
|
wg.Wait()
|
|
total, err := repo.Get(context.Background(), org, constants.GameRewardScopeGame, game, day)
|
|
require.NoError(t, err)
|
|
assert.EqualValues(t, round.wantTotal, total)
|
|
if round.each == 10 {
|
|
assert.EqualValues(t, 100, granted.Load(), "exactly what fits")
|
|
} else {
|
|
assert.Zero(t, granted.Load(), "nothing is left")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGameRewardCounterRepository(t *testing.T) {
|
|
db := walletTestDB(t)
|
|
org, customer := uuid.New(), uuid.New()
|
|
t.Cleanup(func() { db.Exec(`DELETE FROM game_reward_counters WHERE organization_id = ?`, org) })
|
|
repo := NewGameRewardCounterRepository(db)
|
|
txm := NewTxManager(db)
|
|
ctx := context.Background()
|
|
today, tomorrow := time.Date(2026, 10, 7, 23, 0, 0, 0, time.FixedZone("WIB", 7*3600)), time.Date(2026, 10, 8, 0, 0, 0, 0, time.UTC)
|
|
consume := func(day time.Time, amount, limit int64) int64 {
|
|
t.Helper()
|
|
var got int64
|
|
require.NoError(t, txm.WithTransaction(ctx, func(ctx context.Context) error {
|
|
var err error
|
|
got, err = repo.Consume(ctx, org, constants.GameRewardScopeUser, customer, day, amount, limit)
|
|
return err
|
|
}))
|
|
return got
|
|
}
|
|
|
|
assert.EqualValues(t, 30, consume(today, 30, 40))
|
|
assert.EqualValues(t, 10, consume(today, 30, 40), "cut to what is left")
|
|
assert.EqualValues(t, 0, consume(today, 5, 40))
|
|
assert.EqualValues(t, 5, consume(today, 5, 0), "no limit, still counted")
|
|
assert.EqualValues(t, 30, consume(tomorrow, 30, 40), "a new day starts over")
|
|
got, err := repo.Get(ctx, org, constants.GameRewardScopeUser, customer, today)
|
|
require.NoError(t, err)
|
|
assert.EqualValues(t, 45, got)
|
|
|
|
require.NoError(t, txm.WithTransaction(ctx, func(ctx context.Context) error {
|
|
return repo.Release(ctx, org, constants.GameRewardScopeUser, customer, today, 15)
|
|
}))
|
|
got, _ = repo.Get(ctx, org, constants.GameRewardScopeUser, customer, today)
|
|
assert.EqualValues(t, 30, got)
|
|
assert.Error(t, txm.WithTransaction(ctx, func(ctx context.Context) error {
|
|
return repo.Release(ctx, org, constants.GameRewardScopeUser, customer, today, 31)
|
|
}), "cannot release more than counted")
|
|
|
|
// A rolled back reward leaves the counter as it was.
|
|
_ = txm.WithTransaction(ctx, func(ctx context.Context) error {
|
|
_, err := repo.Consume(ctx, org, constants.GameRewardScopeUser, customer, today, 10, 0)
|
|
require.NoError(t, err)
|
|
return assert.AnError
|
|
})
|
|
got, _ = repo.Get(ctx, org, constants.GameRewardScopeUser, customer, today)
|
|
assert.EqualValues(t, 30, got)
|
|
|
|
_, err = repo.Consume(ctx, org, constants.GameRewardScopeUser, customer, today, 1, 0)
|
|
assert.ErrorIs(t, err, ErrGameRewardCounterTxRequired)
|
|
got, err = repo.Get(ctx, org, constants.GameRewardScopeGlobal, org, today)
|
|
require.NoError(t, err)
|
|
assert.Zero(t, got)
|
|
}
|