Files

355 lines
13 KiB
Go
Raw Permalink Normal View History

package processor
import (
"context"
"errors"
"testing"
"time"
"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"
)
// walletMoveEnv runs exchanges and transfers on the in-memory wallet, so every test
// also checks the ยง7.5 invariants when it ends.
type walletMoveEnv struct {
*walletTestEnv
customers *walletMoveRepoFake
settings *models.OrganizationLoyaltySettings
pins *movePinFake
}
func newWalletMoveEnv(t *testing.T) *walletMoveEnv {
e := &walletMoveEnv{
walletTestEnv: newWalletTestEnv(t),
customers: &walletMoveRepoFake{byID: map[uuid.UUID]*repository.WalletMoveCustomer{}},
settings: &models.OrganizationLoyaltySettings{
PointValue: 1,
Exchange: models.LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1},
Transfer: models.LoyaltyTransferSettings{Enabled: true, MinAmount: 1},
},
pins: &movePinFake{good: "482913"},
}
e.customers.ledger = e.repo
// Ledger rows are stamped from now on, so "today" is the day of e.now.
e.repo.clock = e.now
return e
}
// member adds an active customer of the organization.
func (e *walletMoveEnv) member(name, phone string) uuid.UUID {
id := e.customer()
e.customers.byID[id] = &repository.WalletMoveCustomer{
ID: id, OrganizationID: e.org, Name: name, PhoneNumber: &phone, IsActive: true,
}
return id
}
func (e *walletMoveEnv) exchanges() *WalletExchangeProcessor {
p := NewWalletExchangeProcessor(e.customers, e, e, e.pins, e.p, txRunnerFake{})
p.now = func() time.Time { return e.now }
return p
}
func (e *walletMoveEnv) transfers(notifier customerNotifier) *WalletTransferProcessor {
p := NewWalletTransferProcessor(e.customers, e, e, e.pins, e.p, txRunnerFake{}, notifier)
p.now = func() time.Time { return e.now }
return p
}
func (e *walletMoveEnv) Organization(context.Context, uuid.UUID) (*models.OrganizationLoyaltySettings, error) {
s := *e.settings
return &s, nil
}
// SpendableBalances sums the unexpired lots, as the real query does.
func (e *walletMoveEnv) SpendableBalances(_ context.Context, customerID uuid.UUID, asOf time.Time) (map[string]int64, error) {
out := map[string]int64{}
for _, lot := range e.repo.lots {
if lot.CustomerID == customerID && (lot.ExpiresAt == nil || lot.ExpiresAt.After(asOf)) {
out[lot.Currency] += lot.RemainingAmount
}
}
return out, nil
}
// earnCoins gives a customer an EnakCoin lot.
func (e *walletMoveEnv) earnCoins(t *testing.T, customerID uuid.UUID, amount int64, expiresAt *time.Time) *WalletResult {
t.Helper()
in := earn(customerID, amount, expiresAt)
in.Currency = constants.WalletCurrencyCoin
return e.credit(t, in)
}
func (e *walletMoveEnv) coinBalance(t *testing.T, customerID uuid.UUID) int64 {
t.Helper()
w, err := e.repo.GetWallet(e.ctx, customerID)
require.NoError(t, err)
return w.CoinBalance
}
type walletMoveRepoFake struct {
byID map[uuid.UUID]*repository.WalletMoveCustomer
ledger *walletRepoFake
}
func (f *walletMoveRepoFake) GetCustomer(_ context.Context, id uuid.UUID) (*repository.WalletMoveCustomer, error) {
c, ok := f.byID[id]
if !ok {
return nil, repository.ErrWalletNotFound
}
copied := *c
return &copied, nil
}
func (f *walletMoveRepoFake) FindCustomerByPhone(ctx context.Context, phone string) (*repository.WalletMoveCustomer, error) {
for id, c := range f.byID {
if c.PhoneNumber != nil && *c.PhoneNumber == phone {
return f.GetCustomer(ctx, id)
}
}
return nil, repository.ErrWalletNotFound
}
func (f *walletMoveRepoFake) TransferredOutSince(_ context.Context, customerID uuid.UUID, currency string, since time.Time) (int64, error) {
var total int64
for _, tx := range f.ledger.transactions {
if tx.CustomerID == customerID && tx.Currency == currency && tx.Type == constants.WalletTxTypeTransferOut && !tx.CreatedAt.Before(since) {
total -= tx.Amount
}
}
return total, nil
}
// movePinFake accepts one PIN and records the actions it was asked to approve.
type movePinFake struct {
good string
err error
actions []PinAction
}
func (f *movePinFake) VerifyPin(_ context.Context, _ uuid.UUID, pin string, action PinAction, _ models.CustomerPinRequestInfo) error {
f.actions = append(f.actions, action)
if f.err != nil {
return f.err
}
if pin != f.good {
return &PinError{Code: PinErrInvalid, RemainingAttempts: 4}
}
return nil
}
func TestWalletExchange_DefaultRateIsOneToOne(t *testing.T) {
e := newWalletMoveEnv(t)
c := e.member("Budi Santoso", "081234561234")
e.earnCoins(t, c, 50, nil)
res, err := e.exchanges().Exchange(e.ctx, c, 50, "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
assert.Equal(t, int64(50), res.Points)
assert.Equal(t, int64(0), res.CoinBalance)
assert.Equal(t, int64(50), res.PointBalance)
assert.Equal(t, []PinAction{PinActionExchange}, e.pins.actions)
out, in := e.repo.transactions[1], e.repo.transactions[2]
assert.Equal(t, constants.WalletTxTypeExchangeOut, out.Type)
assert.Equal(t, constants.WalletCurrencyCoin, out.Currency)
assert.Equal(t, int64(-50), out.Amount)
assert.Equal(t, constants.WalletTxTypeExchangeIn, in.Type)
assert.Equal(t, constants.WalletCurrencyPoint, in.Currency)
assert.Equal(t, int64(50), in.Amount)
// The pair shares a group and each row points at the other (ยง8.1).
assert.Equal(t, *out.GroupID, *in.GroupID)
assert.Equal(t, res.GroupID, *out.GroupID)
assert.Equal(t, in.ID, out.ReferenceID)
assert.Equal(t, out.ID, in.ReferenceID)
assert.Equal(t, "Tukar 50 EnakCoin ke EnakPoint", out.Description)
assert.Equal(t, "Dari tukar 50 EnakCoin", in.Description)
// Both rows freeze the rate.
for _, row := range []*entities.WalletTransaction{out, in} {
assert.Equal(t, int64(1), row.Metadata["coin_amount"])
assert.Equal(t, int64(1), row.Metadata["point_amount"])
}
}
func TestWalletExchange_TenCoinsForThreePoints(t *testing.T) {
e := newWalletMoveEnv(t)
e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3}
c := e.member("Budi", "081234561234")
e.earnCoins(t, c, 35, nil)
preview, err := e.exchanges().Preview(e.ctx, c, 30)
require.NoError(t, err)
assert.True(t, preview.Valid)
assert.Equal(t, int64(9), preview.Points)
assert.Equal(t, int64(35), preview.CoinBalance)
res, err := e.exchanges().Exchange(e.ctx, c, 30, "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
assert.Equal(t, int64(9), res.Points)
assert.Equal(t, int64(5), res.CoinBalance)
assert.Equal(t, int64(9), res.PointBalance)
}
func TestWalletExchange_RefusesAmountsThatAreNotAMultiple(t *testing.T) {
e := newWalletMoveEnv(t)
e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3}
c := e.member("Budi", "081234561234")
e.earnCoins(t, c, 50, nil)
preview, err := e.exchanges().Preview(e.ctx, c, 25)
require.NoError(t, err)
assert.False(t, preview.Valid)
assert.Contains(t, preview.Reason, "multiples of 10")
_, err = e.exchanges().Exchange(e.ctx, c, 25, "482913", "key-1", models.CustomerPinRequestInfo{})
assert.ErrorIs(t, err, ErrWalletMoveRejected)
// Refused before the PIN, so a typo costs no attempt.
assert.Empty(t, e.pins.actions)
assert.Equal(t, int64(50), e.coinBalance(t, c))
_, err = e.exchanges().Exchange(e.ctx, c, 0, "482913", "key-2", models.CustomerPinRequestInfo{})
assert.ErrorIs(t, err, ErrWalletMoveRejected)
}
func TestWalletExchange_NeverOutlivesTheCoinLot(t *testing.T) {
e := newWalletMoveEnv(t)
e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3}
c := e.member("Budi", "081234561234")
soon, later := e.at(24*time.Hour), e.at(48*time.Hour)
first := e.earnCoins(t, c, 15, soon)
second := e.earnCoins(t, c, 15, later)
third := e.earnCoins(t, c, 10, nil)
// 40 EnakCoin take 15 from the lot expiring soonest, 15 from the next and 10 from
// the one that never expires, giving 12 EnakPoint split 4 + 5 + 3.
res, err := e.exchanges().Exchange(e.ctx, c, 40, "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
require.Len(t, res.Lots, 3)
assert.Equal(t, models.WalletMovedLot{Amount: 4, ExpiresAt: soon}, res.Lots[0])
assert.Equal(t, models.WalletMovedLot{Amount: 5, ExpiresAt: later}, res.Lots[1])
assert.Equal(t, models.WalletMovedLot{Amount: 3}, res.Lots[2])
origins := map[uuid.UUID]*time.Time{}
for _, lot := range e.repo.lots {
if lot.Currency == constants.WalletCurrencyPoint {
require.NotNil(t, lot.OriginLotID)
origins[*lot.OriginLotID] = lot.ExpiresAt
}
}
assert.Equal(t, map[uuid.UUID]*time.Time{
first.Lots[0].ID: soon, second.Lots[0].ID: later, third.Lots[0].ID: nil,
}, origins)
for _, lot := range e.repo.lots {
if lot.Currency != constants.WalletCurrencyPoint || lot.OriginLotID == nil {
continue
}
for _, coinLot := range e.repo.lots {
if coinLot.ID == *lot.OriginLotID && coinLot.ExpiresAt != nil {
require.NotNil(t, lot.ExpiresAt, "a lot that expires cannot become one that does not")
assert.False(t, lot.ExpiresAt.After(*coinLot.ExpiresAt))
}
}
}
}
func TestWalletExchange_LotTooSmallForAWholePointGivesNone(t *testing.T) {
e := newWalletMoveEnv(t)
e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 1}
c := e.member("Budi", "081234561234")
e.earnCoins(t, c, 5, e.at(time.Hour))
e.earnCoins(t, c, 5, nil)
// Neither lot is worth a whole EnakPoint alone; the one that completes the 10
// carries it.
res, err := e.exchanges().Exchange(e.ctx, c, 10, "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
assert.Equal(t, []models.WalletMovedLot{{Amount: 1}}, res.Lots)
}
func TestWalletExchange_NotEnoughCoins(t *testing.T) {
e := newWalletMoveEnv(t)
c := e.member("Budi", "081234561234")
e.earnCoins(t, c, 5, nil)
preview, err := e.exchanges().Preview(e.ctx, c, 6)
require.NoError(t, err)
assert.False(t, preview.Valid)
_, err = e.exchanges().Exchange(e.ctx, c, 6, "482913", "key-1", models.CustomerPinRequestInfo{})
assert.ErrorIs(t, err, ErrWalletMoveRejected)
assert.Equal(t, int64(5), e.coinBalance(t, c))
}
func TestWalletExchange_WrongPinMovesNothing(t *testing.T) {
e := newWalletMoveEnv(t)
c := e.member("Budi", "081234561234")
e.earnCoins(t, c, 5, nil)
_, err := e.exchanges().Exchange(e.ctx, c, 5, "000000", "key-1", models.CustomerPinRequestInfo{})
var pinErr *PinError
require.True(t, errors.As(err, &pinErr))
assert.Equal(t, PinErrInvalid, pinErr.Code)
assert.Equal(t, int64(5), e.coinBalance(t, c))
}
func TestWalletExchange_RetryReturnsTheFirstExchangeAtItsRate(t *testing.T) {
e := newWalletMoveEnv(t)
c := e.member("Budi", "081234561234")
e.earnCoins(t, c, 100, nil)
first, err := e.exchanges().Exchange(e.ctx, c, 40, "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
// The rate changes before the retry arrives; the retry still gets the first result.
e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 2}
again, err := e.exchanges().Exchange(e.ctx, c, 40, "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
assert.True(t, again.Replayed)
assert.Equal(t, first.GroupID, again.GroupID)
assert.Equal(t, int64(40), again.Points)
assert.Equal(t, int64(60), e.coinBalance(t, c))
assert.Len(t, e.repo.transactions, 3)
// The same key for a different amount is not a retry.
_, err = e.exchanges().Exchange(e.ctx, c, 20, "482913", "key-1", models.CustomerPinRequestInfo{})
assert.ErrorIs(t, err, ErrWalletIdempotencyConflict)
}
func TestWalletExchange_RequiresAnIdempotencyKey(t *testing.T) {
e := newWalletMoveEnv(t)
c := e.member("Budi", "081234561234")
e.earnCoins(t, c, 5, nil)
_, err := e.exchanges().Exchange(e.ctx, c, 5, "482913", " ", models.CustomerPinRequestInfo{})
assert.ErrorIs(t, err, ErrWalletMoveRejected)
assert.Empty(t, e.pins.actions)
}
// F4: EnakPoint from an exchange expire at the sooner of the EnakCoin lot's expiry and
// when EnakPoint received now would expire.
func TestWalletExchange_CappedByThePointExpiry(t *testing.T) {
e := newWalletMoveEnv(t)
e.now = wib(2026, 6, 1, 10, 0)
e.settings.PointExpiry = rolling(30, "DAY", false)
c := e.member("Budi", "081234561234")
soon, later := e.at(24*time.Hour), e.at(90*24*time.Hour)
e.earnCoins(t, c, 10, soon)
e.earnCoins(t, c, 10, later)
e.earnCoins(t, c, 10, nil)
res, err := e.exchanges().Exchange(e.ctx, c, 30, "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
require.Len(t, res.Lots, 3)
assert.Equal(t, soon, res.Lots[0].ExpiresAt, "the EnakCoin lot expires first")
assert.Equal(t, "2026-07-01", expiryDay(t, res.Lots[1].ExpiresAt), "30 days of EnakPoint come first")
assert.Equal(t, "2026-07-01", expiryDay(t, res.Lots[2].ExpiresAt), "a lot that never expired gets the EnakPoint expiry")
}