Files
apskel-pos-backend/internal/processor/game_play_processor_test.go
T
efrilmandClaude Opus 5.5 a18bb072f5 feat(loyalty): pay every game with EnakCoin
Games now spend the wallet's EnakCoin instead of the per-type tokens
(docs/prd-point-coin.md F8, K1, PC-403).

GamePlayProcessor.PlayGame charges the game's metadata.coin_cost, 1 when
it is not set; a cost that is not a whole number of at least 1 refuses the
game. In one transaction it picks the prize, takes the EnakCoin with a
GAME_SPEND row pointing at the new game_plays.id (which locks the wallet,
so a customer's plays at the same time queue up), records the play and
takes the prize from stock. The play owns its transaction, so the spin
service no longer wraps it, and the admin play endpoint is now atomic too.

The game, game prize and game play repositories go through DBFromContext
so they join that transaction. DecreaseStock now reports a prize that ran
out (ErrGamePrizeOutOfStock) instead of silently updating nothing; that,
or any other stock failure, cancels the whole play, where it used to be
only printed. The manual AddTokens rollback is gone. Not enough EnakCoin,
an inactive game or a prize that ran out answer 400 on /customer/spin
instead of 500.

game_plays.token_used is renamed coins_used (migration 000095). What a
play costs is no longer the caller's choice, so PlayGameRequest loses
token_used. Responses carry coins_used and coins_remaining; token_used and
tokens_remaining stay as deprecated copies until the apps move over, and
sort_by=token_used still sorts by coins_used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:20:42 +07:00

246 lines
7.4 KiB
Go

package processor
import (
"context"
"errors"
"testing"
"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"
)
// snapshot saves everything the fake wallet holds and returns what puts it back, so
// a test transaction can roll back like a real one.
func (f *walletRepoFake) snapshot() func() {
wallets := make(map[uuid.UUID]*entities.CustomerWallet, len(f.wallets))
for id, w := range f.wallets {
c := *w
wallets[id] = &c
}
transactions := make([]*entities.WalletTransaction, len(f.transactions))
for i, t := range f.transactions {
c := *t
transactions[i] = &c
}
lots := make([]*entities.WalletLot, len(f.lots))
for i, l := range f.lots {
c := *l
lots[i] = &c
}
allocations := append([]entities.WalletLotAllocation(nil), f.allocations...)
return func() {
f.wallets, f.transactions, f.lots, f.allocations = wallets, transactions, lots, allocations
}
}
// rollbackTx runs fn in place and, when it fails, puts back what each save saved.
type rollbackTx struct{ saves []func() func() }
func (r rollbackTx) WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error {
restores := make([]func(), 0, len(r.saves))
for _, save := range r.saves {
restores = append(restores, save())
}
err := fn(ctx)
if err != nil {
for _, restore := range restores {
restore()
}
}
return err
}
type gameFake struct{ games map[uuid.UUID]*entities.Game }
func (f gameFake) GetByID(_ context.Context, id uuid.UUID) (*entities.Game, error) {
g, ok := f.games[id]
if !ok {
return nil, errors.New("record not found")
}
return g, nil
}
type prizeStockFake struct {
prizes []entities.GamePrize
stockErr error
}
func (f *prizeStockFake) GetAvailablePrizes(_ context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) {
var out []entities.GamePrize
for _, p := range f.prizes {
if p.GameID == gameID && p.Stock > 0 {
out = append(out, p)
}
}
return out, nil
}
func (f *prizeStockFake) DecreaseStock(_ context.Context, id uuid.UUID, amount int) error {
if f.stockErr != nil {
return f.stockErr
}
for i := range f.prizes {
if f.prizes[i].ID == id {
if f.prizes[i].Stock < amount {
return repository.ErrGamePrizeOutOfStock
}
f.prizes[i].Stock -= amount
return nil
}
}
return repository.ErrGamePrizeOutOfStock
}
func (f *prizeStockFake) snapshot() func() {
prizes := append([]entities.GamePrize(nil), f.prizes...)
return func() { f.prizes = prizes }
}
// gamePlayRepoFake records plays; the other methods of the interface are not used.
type gamePlayRepoFake struct {
repository.GamePlayRepository
plays []entities.GamePlay
}
func (f *gamePlayRepoFake) Create(_ context.Context, gp *entities.GamePlay) error {
f.plays = append(f.plays, *gp)
return nil
}
func (f *gamePlayRepoFake) snapshot() func() {
plays := append([]entities.GamePlay(nil), f.plays...)
return func() { f.plays = plays }
}
type gameTestEnv struct {
*walletMoveEnv
game *entities.Game
prize entities.GamePrize
stock *prizeStockFake
plays *gamePlayRepoFake
player uuid.UUID
p *GamePlayProcessor
}
func newGameTestEnv(t *testing.T, metadata entities.Metadata) *gameTestEnv {
e := &gameTestEnv{walletMoveEnv: newWalletMoveEnv(t)}
e.game = &entities.Game{ID: uuid.New(), Name: "Spin Wheel", Type: entities.GameTypeSpin, IsActive: true, Metadata: metadata}
e.prize = entities.GamePrize{ID: uuid.New(), GameID: e.game.ID, Name: "Voucher 10rb", Weight: 1, Stock: 5}
e.stock = &prizeStockFake{prizes: []entities.GamePrize{e.prize}}
e.plays = &gamePlayRepoFake{}
e.player = e.member("Budi", "081234561234")
tx := rollbackTx{saves: []func() func(){e.repo.snapshot, e.stock.snapshot, e.plays.snapshot}}
e.p = NewGamePlayProcessor(e.plays, gameFake{games: map[uuid.UUID]*entities.Game{e.game.ID: e.game}}, e.stock, e.walletTestEnv.p, e, tx)
return e
}
func (e *gameTestEnv) play() (*models.PlayGameResponse, error) {
return e.p.PlayGame(e.ctx, &models.PlayGameRequest{GameID: e.game.ID, CustomerID: e.player})
}
func TestPlayGame_SpendsTheGamesCoinCost(t *testing.T) {
// coin_cost comes back from JSONB as a float64.
e := newGameTestEnv(t, entities.Metadata{"coin_cost": float64(3)})
e.earnCoins(t, e.player, 10, nil)
res, err := e.play()
require.NoError(t, err)
assert.Equal(t, int64(7), res.CoinsRemaining)
require.NotNil(t, res.PrizeWon)
assert.Equal(t, e.prize.ID, res.PrizeWon.ID)
require.Len(t, e.plays.plays, 1)
play := e.plays.plays[0]
assert.Equal(t, 3, play.CoinsUsed)
assert.Equal(t, e.prize.ID, *play.PrizeID)
assert.Equal(t, 4, e.stock.prizes[0].Stock)
spend := e.repo.transactions[len(e.repo.transactions)-1]
assert.Equal(t, constants.WalletTxTypeGameSpend, spend.Type)
assert.Equal(t, constants.WalletCurrencyCoin, spend.Currency)
assert.Equal(t, int64(-3), spend.Amount)
assert.Equal(t, constants.WalletRefTypeGamePlay, spend.ReferenceType)
assert.Equal(t, play.ID, spend.ReferenceID, "GAME_SPEND points at the game play")
assert.Equal(t, "Main Spin Wheel: dapat Voucher 10rb", spend.Description)
}
func TestPlayGame_CostsOneCoinByDefault(t *testing.T) {
e := newGameTestEnv(t, entities.Metadata{})
e.earnCoins(t, e.player, 2, nil)
res, err := e.play()
require.NoError(t, err)
assert.Equal(t, int64(1), res.CoinsRemaining)
assert.Equal(t, 1, e.plays.plays[0].CoinsUsed)
}
func TestPlayGame_NotEnoughCoinsRecordsNothing(t *testing.T) {
e := newGameTestEnv(t, entities.Metadata{"coin_cost": float64(3)})
e.earnCoins(t, e.player, 2, nil)
_, err := e.play()
assert.ErrorIs(t, err, ErrGamePlayRefused)
assert.Empty(t, e.plays.plays)
assert.Equal(t, 5, e.stock.prizes[0].Stock)
assert.Equal(t, int64(2), e.coinBalance(t, e.player))
}
func TestPlayGame_PrizeStockFailureCancelsThePlay(t *testing.T) {
e := newGameTestEnv(t, entities.Metadata{})
e.earnCoins(t, e.player, 5, nil)
rows := len(e.repo.transactions)
// Another play took the last one between reading the prizes and taking it.
e.stock.stockErr = repository.ErrGamePrizeOutOfStock
_, err := e.play()
assert.ErrorIs(t, err, ErrGamePlayRefused)
// Anything else going wrong with the stock cancels it too.
e.stock.stockErr = errors.New("connection reset")
_, err = e.play()
assert.Error(t, err)
assert.Empty(t, e.plays.plays)
assert.Len(t, e.repo.transactions, rows, "no GAME_SPEND is left behind")
assert.Equal(t, int64(5), e.coinBalance(t, e.player))
}
func TestPlayGame_RefusesWhatCannotBePlayed(t *testing.T) {
for name, metadata := range map[string]entities.Metadata{
"zero": {"coin_cost": float64(0)},
"fraction": {"coin_cost": 1.5},
"not number": {"coin_cost": "2"},
} {
t.Run(name, func(t *testing.T) {
e := newGameTestEnv(t, metadata)
e.earnCoins(t, e.player, 5, nil)
_, err := e.play()
assert.ErrorIs(t, err, ErrGamePlayRefused)
assert.Equal(t, int64(5), e.coinBalance(t, e.player))
})
}
t.Run("inactive", func(t *testing.T) {
e := newGameTestEnv(t, entities.Metadata{})
e.earnCoins(t, e.player, 5, nil)
e.game.IsActive = false
_, err := e.play()
assert.ErrorIs(t, err, ErrGamePlayRefused)
})
t.Run("no prizes left", func(t *testing.T) {
e := newGameTestEnv(t, entities.Metadata{})
e.earnCoins(t, e.player, 5, nil)
e.stock.prizes[0].Stock = 0
_, err := e.play()
assert.ErrorIs(t, err, ErrGamePlayRefused)
assert.Equal(t, int64(5), e.coinBalance(t, e.player))
})
}