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