Files
apskel-pos-backend/internal/processor/game_play_processor_test.go
T

246 lines
7.4 KiB
Go
Raw Normal View History

2026-09-30 12:20:42 +07:00
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))
})
}