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>
71 lines
3.0 KiB
Go
71 lines
3.0 KiB
Go
package processor
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"apskel-pos-be/internal/constants"
|
|
"apskel-pos-be/internal/models"
|
|
"apskel-pos-be/internal/repository"
|
|
)
|
|
|
|
// Plays against Postgres, so the game repositories really join the transaction: a
|
|
// refused play leaves no game_plays row and no GAME_SPEND.
|
|
func TestPlayGame_AgainstPostgres(t *testing.T) {
|
|
db, _, player, _ := walletMoveDB(t)
|
|
gameID, prizeID := uuid.New(), uuid.New()
|
|
// An old game: archived for EnakGame (000103) but still active for the old spin.
|
|
require.NoError(t, db.Exec(`INSERT INTO games (id, name, type, metadata, status, entry_cost) VALUES (?, 'Raffle', 'RAFFLE', '{"coin_cost": 2}', 'ARCHIVED', 2)`, gameID).Error)
|
|
require.NoError(t, db.Exec(`INSERT INTO game_prizes (id, game_id, name, weight, stock) VALUES (?, ?, 'Tumbler', 1, 1)`, prizeID, gameID).Error)
|
|
t.Cleanup(func() {
|
|
db.Exec(`DELETE FROM wallet_lot_allocations WHERE transaction_id IN (SELECT id FROM wallet_transactions WHERE customer_id = ?)`, player)
|
|
db.Exec(`DELETE FROM game_plays WHERE game_id = ?`, gameID)
|
|
db.Exec(`DELETE FROM game_prizes WHERE game_id = ?`, gameID)
|
|
db.Exec(`DELETE FROM games WHERE id = ?`, gameID)
|
|
})
|
|
|
|
wallet := NewWalletProcessor(repository.NewWalletRepository(db))
|
|
txm := repository.NewTxManager(db)
|
|
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
|
in := earn(player, 3, nil)
|
|
in.Currency = constants.WalletCurrencyCoin
|
|
_, err := wallet.Credit(ctx, in)
|
|
return err
|
|
}))
|
|
p := NewGamePlayProcessor(repository.NewGamePlayRepository(db), repository.NewGameRepository(db), repository.NewGamePrizeRepository(db),
|
|
wallet, repository.NewWalletQueryRepository(db), txm)
|
|
play := func() (*models.PlayGameResponse, error) {
|
|
return p.PlayGame(context.Background(), &models.PlayGameRequest{GameID: gameID, CustomerID: player})
|
|
}
|
|
|
|
res, err := play()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(1), res.CoinsRemaining)
|
|
assert.Equal(t, 2, res.GamePlay.CoinsUsed)
|
|
|
|
var spend struct {
|
|
ReferenceID uuid.UUID
|
|
Amount int64
|
|
}
|
|
require.NoError(t, db.Raw(`SELECT reference_id, amount FROM wallet_transactions WHERE customer_id = ? AND type = 'GAME_SPEND'`, player).Scan(&spend).Error)
|
|
assert.Equal(t, res.GamePlay.ID, spend.ReferenceID)
|
|
assert.Equal(t, int64(-2), spend.Amount)
|
|
|
|
// The only prize is gone, then the EnakCoin are too short: neither play leaves a row.
|
|
_, err = play()
|
|
assert.ErrorIs(t, err, ErrGamePlayRefused)
|
|
require.NoError(t, db.Exec(`UPDATE game_prizes SET stock = 5 WHERE id = ?`, prizeID).Error)
|
|
_, err = play()
|
|
assert.ErrorIs(t, err, ErrGamePlayRefused)
|
|
|
|
var plays, spends int64
|
|
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM game_plays WHERE game_id = ? AND coins_used = 2`, gameID).Scan(&plays).Error)
|
|
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE customer_id = ? AND type = 'GAME_SPEND'`, player).Scan(&spends).Error)
|
|
assert.Equal(t, int64(1), plays)
|
|
assert.Equal(t, int64(1), spends)
|
|
}
|