Files
apskel-pos-backend/internal/processor/wallet_query_processor_test.go
T
efrilmandClaude Opus 5.5 39e47ff0e6 feat(loyalty): typed loyalty settings with change history
Adds LoyaltySettingsProcessor (docs/prd-point-coin.md F1, F2, F12, PC-109).

Reading returns typed settings for an outlet (earning per currency, paying
with EnakPoint) and for an organization (point value, exchange rate,
transfers, and the expiry settings awaiting note N4). A key that was never
set takes the PRD default. A stored value that is unusable, such as an
earn_per_amount of 0 that would divide by zero, also falls back to the
default and is logged, so a bad row never reaches a calculation.

Writing takes the whole settings struct, validates every rule in the PRD
before touching the database, and stores and records in
loyalty_setting_changes only the keys whose effective value changes: old
value (NULL while it was on its default), new value, and who changed it.
Clearing a limit deletes the stored value. Each save runs in one
transaction under an advisory lock per outlet or organization, so two saves
at once cannot both compute their change from the same old value. The
outlet must belong to the caller's organization.

Every key is described once (key, default, valid range, bound field), and
reading, validating and diffing all use that description.

GET /customer/wallet now reads the point value through this processor; the
minimal organization settings repository from PC-106 is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:23:08 +07:00

242 lines
9.9 KiB
Go

package processor
import (
"context"
"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"
)
type walletQueryRepoFake struct {
org uuid.UUID
spendable map[string]int64
expiring []repository.WalletExpiringAmount
transactions []entities.WalletTransaction
total int64
lots []entities.WalletLot
filters []repository.WalletTransactionFilter
}
func (f *walletQueryRepoFake) GetCustomerOrganizationID(context.Context, uuid.UUID) (uuid.UUID, error) {
if f.org == uuid.Nil {
return uuid.Nil, repository.ErrWalletNotFound
}
return f.org, nil
}
func (f *walletQueryRepoFake) SpendableBalances(context.Context, uuid.UUID, time.Time) (map[string]int64, error) {
return f.spendable, nil
}
func (f *walletQueryRepoFake) NearestExpiring(context.Context, uuid.UUID, time.Time) ([]repository.WalletExpiringAmount, error) {
return f.expiring, nil
}
func (f *walletQueryRepoFake) ListTransactions(_ context.Context, filter repository.WalletTransactionFilter) ([]entities.WalletTransaction, int64, error) {
f.filters = append(f.filters, filter)
var out []entities.WalletTransaction
for _, tx := range f.transactions {
if filter.Currency == "" || tx.Currency == filter.Currency {
out = append(out, tx)
}
}
if len(out) > filter.Limit {
out = out[:filter.Limit]
}
return out, f.total, nil
}
func (f *walletQueryRepoFake) ListLotsBySourceTransactions(_ context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) {
var out []entities.WalletLot
for _, lot := range f.lots {
for _, id := range ids {
if lot.SourceTransactionID == id {
out = append(out, lot)
}
}
}
return out, nil
}
type orgSettingsFake map[string]string
func newWalletQueryTest(repo *walletQueryRepoFake, settings orgSettingsFake) *WalletQueryProcessor {
p := NewWalletQueryProcessor(repo, NewLoyaltySettingsProcessor(&loyaltyRepoFake{orgValues: settings}, txRunnerFake{}))
p.now = func() time.Time { return time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) }
return p
}
func TestWalletQueryProcessor_SummaryShowsWhereEachRowCameFromOrWent(t *testing.T) {
order, payment := uuid.New(), uuid.New()
earnID, payID := uuid.New(), uuid.New()
soon := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
later := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
created := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
repo := &walletQueryRepoFake{
org: uuid.New(),
spendable: map[string]int64{constants.WalletCurrencyPoint: 250, constants.WalletCurrencyCoin: 8},
expiring: []repository.WalletExpiringAmount{
{Currency: constants.WalletCurrencyPoint, Date: "2026-07-01", Amount: 100},
},
transactions: []entities.WalletTransaction{
{ID: payID, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment, Amount: -50, BalanceAfter: 250,
ReferenceType: constants.WalletRefTypePayment, ReferenceID: payment, Description: "Bayar #ORD-1", CreatedAt: created.Add(time.Hour),
CounterpartyCustomerID: ptr(uuid.New()), Metadata: entities.Metadata{"point_value": 100}},
{ID: earnID, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeEarn, Amount: 300, BalanceAfter: 300,
ReferenceType: constants.WalletRefTypeOrder, ReferenceID: order, Description: "Belanja #ORD-1", CreatedAt: created},
},
lots: []entities.WalletLot{
{SourceTransactionID: earnID, OriginalAmount: 200, RemainingAmount: 150, ExpiresAt: &later},
{SourceTransactionID: earnID, OriginalAmount: 100, RemainingAmount: 100, ExpiresAt: &soon},
},
}
p := newWalletQueryTest(repo, orgSettingsFake{constants.LoyaltyPointValueKey: "100"})
data, err := p.Summary(context.Background(), uuid.New())
require.NoError(t, err)
assert.Equal(t, int64(250), data.PointBalance)
assert.Equal(t, int64(8), data.CoinBalance)
assert.Equal(t, int64(100), data.PointValue)
assert.Equal(t, int64(25000), data.PointDiscountValue)
assert.Equal(t, &models.CustomerWalletExpiring{Amount: 100, Date: "2026-07-01"}, data.NearestExpiring.Point)
assert.Nil(t, data.NearestExpiring.Coin)
require.Len(t, data.RecentTransactions, 2)
pay, earn := data.RecentTransactions[0], data.RecentTransactions[1]
assert.Equal(t, &models.CustomerWalletTransactionRef{Type: constants.WalletRefTypePayment, ID: payment}, pay.Destination)
assert.Nil(t, pay.Source)
assert.Empty(t, pay.Lots)
assert.Equal(t, &models.CustomerWalletTransactionRef{Type: constants.WalletRefTypeOrder, ID: order}, earn.Source)
assert.Nil(t, earn.Destination)
assert.Equal(t, &soon, earn.ExpiresAt, "earliest expiry among the lots the credit created")
assert.Len(t, earn.Lots, 2)
// The pre-wallet fields are still filled, from the wallet.
assert.Equal(t, int64(250), data.TotalPoints)
assert.Equal(t, int64(8), data.TotalTokens)
require.Len(t, data.PointsHistory, 2)
assert.Equal(t, int64(-50), data.PointsHistory[0].Points)
assert.Equal(t, constants.WalletTxTypePayment, data.PointsHistory[0].Type)
assert.Equal(t, created.Add(time.Hour), data.LastUpdated)
}
func TestWalletQueryProcessor_PointValueFallsBackToDefault(t *testing.T) {
for name, settings := range map[string]orgSettingsFake{
"not set": {},
"garbage": {constants.LoyaltyPointValueKey: "abc"},
"zero": {constants.LoyaltyPointValueKey: "0"},
"negative": {constants.LoyaltyPointValueKey: "-5"},
} {
t.Run(name, func(t *testing.T) {
repo := &walletQueryRepoFake{org: uuid.New(), spendable: map[string]int64{constants.WalletCurrencyPoint: 40}}
data, err := newWalletQueryTest(repo, settings).Summary(context.Background(), uuid.New())
require.NoError(t, err)
assert.Equal(t, constants.LoyaltyPointValueDefault, data.PointValue)
assert.Equal(t, int64(40), data.PointDiscountValue)
})
}
}
func TestWalletQueryProcessor_SummaryForCustomerWithoutWallet(t *testing.T) {
repo := &walletQueryRepoFake{org: uuid.New(), spendable: map[string]int64{}}
data, err := newWalletQueryTest(repo, orgSettingsFake{}).Summary(context.Background(), uuid.New())
require.NoError(t, err)
assert.Zero(t, data.PointBalance)
assert.Zero(t, data.CoinBalance)
assert.NotNil(t, data.RecentTransactions, "an empty list, not null, for the app")
_, err = newWalletQueryTest(&walletQueryRepoFake{}, orgSettingsFake{}).Summary(context.Background(), uuid.New())
assert.ErrorIs(t, err, repository.ErrWalletNotFound)
}
func TestWalletQueryProcessor_LegacyEndpointsSplitByCurrency(t *testing.T) {
repo := &walletQueryRepoFake{
org: uuid.New(),
spendable: map[string]int64{constants.WalletCurrencyPoint: 100, constants.WalletCurrencyCoin: 8},
transactions: []entities.WalletTransaction{
{ID: uuid.New(), Currency: constants.WalletCurrencyCoin, Type: constants.WalletTxTypeMigration, Amount: 8},
{ID: uuid.New(), Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration, Amount: 100},
},
}
p := newWalletQueryTest(repo, orgSettingsFake{})
points, err := p.LegacyPoints(context.Background(), uuid.New())
require.NoError(t, err)
assert.Equal(t, int64(100), points.TotalPoints)
require.Len(t, points.PointsHistory, 1)
assert.Equal(t, int64(100), points.PointsHistory[0].Points)
tokens, err := p.LegacyTokens(context.Background(), uuid.New())
require.NoError(t, err)
assert.Equal(t, int64(8), tokens.TotalTokens)
require.Len(t, tokens.TokensHistory, 1)
assert.Equal(t, int64(8), tokens.TokensHistory[0].Tokens)
}
func TestWalletQueryProcessor_TransactionsQuery(t *testing.T) {
customer := uuid.New()
jakarta := walletDisplayLocation
repo := &walletQueryRepoFake{org: uuid.New(), total: 45}
page, err := newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), customer, models.ListCustomerWalletTransactionsQuery{
Page: 3, Limit: 10, Currency: "point", Type: "earn, PAYMENT", From: "2026-05-01", To: "2026-05-31",
})
require.NoError(t, err)
assert.Equal(t, models.Pagination{Page: 3, Limit: 10, Total: 45, TotalPages: 5}, page.Pagination)
assert.NotNil(t, page.Data)
f := repo.filters[0]
assert.Equal(t, customer, f.CustomerID)
assert.Equal(t, 20, f.Offset)
assert.Equal(t, 10, f.Limit)
assert.Equal(t, constants.WalletCurrencyPoint, f.Currency)
assert.Equal(t, []string{constants.WalletTxTypeEarn, constants.WalletTxTypePayment}, f.Types)
assert.True(t, f.From.Equal(time.Date(2026, 5, 1, 0, 0, 0, 0, jakarta)))
assert.True(t, f.To.Equal(time.Date(2026, 6, 1, 0, 0, 0, 0, jakarta)), "to covers the whole last day")
// Defaults.
repo = &walletQueryRepoFake{org: uuid.New()}
page, err = newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), customer, models.ListCustomerWalletTransactionsQuery{})
require.NoError(t, err)
assert.Equal(t, 1, page.Pagination.Page)
assert.Equal(t, walletTransactionsPageLimit, repo.filters[0].Limit)
assert.Zero(t, repo.filters[0].Offset)
assert.Nil(t, repo.filters[0].From)
assert.Empty(t, repo.filters[0].Types)
}
func TestWalletQueryProcessor_RejectsBadQueries(t *testing.T) {
for name, q := range map[string]models.ListCustomerWalletTransactionsQuery{
"negative page": {Page: -1},
"limit too big": {Limit: walletTransactionsMaxLimit + 1},
"negative limit": {Limit: -1},
"unknown currency": {Currency: "GOLD"},
"unknown type": {Type: "EARN,BONUS"},
"bad from": {From: "01-05-2026"},
"bad to": {To: "2026-13-01"},
"from after to": {From: "2026-06-02", To: "2026-06-01"},
} {
t.Run(name, func(t *testing.T) {
repo := &walletQueryRepoFake{org: uuid.New()}
_, err := newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), uuid.New(), q)
assert.ErrorIs(t, err, ErrInvalidWalletQuery)
assert.Empty(t, repo.filters, "nothing is queried")
})
}
// A single day is a valid range.
repo := &walletQueryRepoFake{org: uuid.New()}
_, err := newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), uuid.New(),
models.ListCustomerWalletTransactionsQuery{From: "2026-06-01", To: "2026-06-01"})
assert.NoError(t, err)
}