Files
apskel-pos-backend/internal/processor/loyalty_settings_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

323 lines
12 KiB
Go

package processor
import (
"context"
"os"
"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/logger"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
func TestMain(m *testing.M) {
// Some processors log; the server sets the logger up at startup.
logger.Setup("fatal", "json")
os.Exit(m.Run())
}
// loyaltyRepoFake keeps settings in maps. It does not check for a transaction.
type loyaltyRepoFake struct {
outletOrg map[uuid.UUID]uuid.UUID
outletValues map[string]string
orgValues map[string]string
changes []entities.LoyaltySettingChange
locks []uuid.UUID
}
func (f *loyaltyRepoFake) GetOutletValues(context.Context, uuid.UUID) (map[string]string, error) {
return copyStringMap(f.outletValues), nil
}
func (f *loyaltyRepoFake) GetOrganizationValues(context.Context, uuid.UUID) (map[string]string, error) {
return copyStringMap(f.orgValues), nil
}
func (f *loyaltyRepoFake) OutletOrganizationID(_ context.Context, outletID uuid.UUID) (uuid.UUID, error) {
org, ok := f.outletOrg[outletID]
if !ok {
return uuid.Nil, repository.ErrLoyaltyOutletNotFound
}
return org, nil
}
func (f *loyaltyRepoFake) LockScope(_ context.Context, id uuid.UUID) error {
f.locks = append(f.locks, id)
return nil
}
func (f *loyaltyRepoFake) SetOutletValue(_ context.Context, _ uuid.UUID, key string, value *string) error {
f.outletValues = setOptional(f.outletValues, key, value)
return nil
}
func (f *loyaltyRepoFake) SetOrganizationValue(_ context.Context, _ uuid.UUID, key string, value *string) error {
f.orgValues = setOptional(f.orgValues, key, value)
return nil
}
func (f *loyaltyRepoFake) InsertChanges(_ context.Context, changes []entities.LoyaltySettingChange) error {
f.changes = append(f.changes, changes...)
return nil
}
func (f *loyaltyRepoFake) ListChanges(context.Context, repository.LoyaltySettingChangeFilter) ([]entities.LoyaltySettingChange, int64, error) {
return f.changes, int64(len(f.changes)), nil
}
func copyStringMap(m map[string]string) map[string]string {
out := make(map[string]string, len(m))
for k, v := range m {
out[k] = v
}
return out
}
func setOptional(m map[string]string, key string, value *string) map[string]string {
if m == nil {
m = map[string]string{}
}
if value == nil {
delete(m, key)
} else {
m[key] = *value
}
return m
}
// txRunnerFake runs fn in place.
type txRunnerFake struct{}
func (txRunnerFake) WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error {
return fn(ctx)
}
func TestLoyaltySettings_OutletWithoutSettingsGetsEveryDefault(t *testing.T) {
p := NewLoyaltySettingsProcessor(&loyaltyRepoFake{}, txRunnerFake{})
s, err := p.Outlet(context.Background(), uuid.New())
require.NoError(t, err)
assert.Equal(t, models.OutletLoyaltySettings{
Point: models.LoyaltyEarnSettings{Enabled: false, EarnPerAmount: 100, EarnValue: 1, MinOrderAmount: 0, MaxPerOrder: nil},
Coin: models.LoyaltyEarnSettings{Enabled: false, EarnPerAmount: 25000, EarnValue: 1, MinOrderAmount: 0, MaxPerOrder: nil},
PointPayment: models.LoyaltyPointPaymentSettings{AcceptPayment: false, MinPaymentPoints: 1, MaxPaymentPercent: 100},
}, *s)
}
func TestLoyaltySettings_OrganizationWithoutSettingsGetsEveryDefault(t *testing.T) {
p := NewLoyaltySettingsProcessor(&loyaltyRepoFake{}, txRunnerFake{})
s, err := p.Organization(context.Background(), uuid.New())
require.NoError(t, err)
expiry := models.LoyaltyExpirySettings{Enabled: false, Period: 12, Unit: "MONTH", EndOfMonth: false, ReminderDays: 7}
assert.Equal(t, models.OrganizationLoyaltySettings{
PointValue: 1,
Exchange: models.LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1},
Transfer: models.LoyaltyTransferSettings{Enabled: true, MinAmount: 1},
PointExpiry: expiry,
CoinExpiry: expiry,
}, *s)
}
func TestLoyaltySettings_StoredValuesAreTyped(t *testing.T) {
repo := &loyaltyRepoFake{
outletValues: map[string]string{
constants.LoyaltyPointEnabledKey: "true",
constants.LoyaltyPointEarnPerAmountKey: " 1000 ",
constants.LoyaltyPointMaxPerOrderKey: "500",
constants.LoyaltyPointMaxPaymentPercentKey: "50",
"loyalty.unknown": "ignored",
},
orgValues: map[string]string{
constants.LoyaltyPointValueKey: "100",
constants.LoyaltyTransferDailyLimitKey: "10000",
"loyalty.coin.expiry_unit": "day",
},
}
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
outlet, err := p.Outlet(context.Background(), uuid.New())
require.NoError(t, err)
assert.True(t, outlet.Point.Enabled)
assert.Equal(t, int64(1000), outlet.Point.EarnPerAmount)
assert.Equal(t, int64(500), *outlet.Point.MaxPerOrder)
assert.Equal(t, int64(50), outlet.PointPayment.MaxPaymentPercent)
org, err := p.Organization(context.Background(), uuid.New())
require.NoError(t, err)
assert.Equal(t, int64(100), org.PointValue)
assert.Equal(t, int64(10000), *org.Transfer.DailyLimit)
assert.Equal(t, "DAY", org.CoinExpiry.Unit)
assert.Equal(t, "MONTH", org.PointExpiry.Unit)
}
// A bad row must never reach a calculation: earn_per_amount 0 would divide by zero.
func TestLoyaltySettings_UnusableStoredValuesFallBackToDefault(t *testing.T) {
repo := &loyaltyRepoFake{
outletValues: map[string]string{
constants.LoyaltyPointEnabledKey: "yes please",
constants.LoyaltyPointEarnPerAmountKey: "0",
constants.LoyaltyCoinEarnValueKey: "-1",
constants.LoyaltyPointMaxPerOrderKey: "abc",
constants.LoyaltyPointMaxPaymentPercentKey: "150",
},
orgValues: map[string]string{
constants.LoyaltyPointValueKey: "0",
"loyalty.point.expiry_unit": "YEAR",
"loyalty.point.expiry_period": "",
},
}
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
outlet, err := p.Outlet(context.Background(), uuid.New())
require.NoError(t, err)
assert.False(t, outlet.Point.Enabled)
assert.Equal(t, int64(100), outlet.Point.EarnPerAmount)
assert.Equal(t, int64(1), outlet.Coin.EarnValue)
assert.Nil(t, outlet.Point.MaxPerOrder)
assert.Equal(t, int64(100), outlet.PointPayment.MaxPaymentPercent)
for name, raw := range map[string]string{"not set": "", "garbage": "abc", "zero": "0", "negative": "-5"} {
repo.orgValues = map[string]string{}
if raw != "" {
repo.orgValues[constants.LoyaltyPointValueKey] = raw
}
value, err := p.PointValue(context.Background(), uuid.New())
require.NoError(t, err)
assert.Equal(t, constants.LoyaltyPointValueDefault, value, name)
}
}
func TestLoyaltySettings_UpdateRecordsOnlyRealChanges(t *testing.T) {
org, outlet, admin := uuid.New(), uuid.New(), uuid.New()
repo := &loyaltyRepoFake{
outletOrg: map[uuid.UUID]uuid.UUID{outlet: org},
outletValues: map[string]string{constants.LoyaltyPointEarnPerAmountKey: "200"},
}
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
ctx := context.Background()
settings, err := p.Outlet(ctx, outlet)
require.NoError(t, err)
settings.Point.Enabled = true // default false -> true
settings.Point.EarnPerAmount = 100 // stored 200 -> 100 (the default, but set explicitly)
settings.Point.MaxPerOrder = ptr(int64(0)) // unset -> 0
// Everything else stays on its default.
changes, err := p.UpdateOutlet(ctx, org, outlet, admin, *settings)
require.NoError(t, err)
byKey := map[string]models.LoyaltySettingChange{}
for _, c := range changes {
byKey[c.Key] = c
assert.Equal(t, admin, c.ChangedBy)
assert.Equal(t, org, c.OrganizationID)
assert.Equal(t, &outlet, c.OutletID)
}
require.Len(t, byKey, 3, "only the three keys that changed")
assert.Nil(t, byKey[constants.LoyaltyPointEnabledKey].OldValue, "was on its default")
assert.Equal(t, "true", *byKey[constants.LoyaltyPointEnabledKey].NewValue)
assert.Equal(t, "200", *byKey[constants.LoyaltyPointEarnPerAmountKey].OldValue)
assert.Equal(t, "100", *byKey[constants.LoyaltyPointEarnPerAmountKey].NewValue)
assert.Equal(t, "0", *byKey[constants.LoyaltyPointMaxPerOrderKey].NewValue)
assert.Len(t, repo.changes, 3)
assert.Equal(t, []uuid.UUID{outlet}, repo.locks)
after, err := p.Outlet(ctx, outlet)
require.NoError(t, err)
assert.Equal(t, *settings, *after)
// Saving the same settings again changes nothing and records nothing.
changes, err = p.UpdateOutlet(ctx, org, outlet, admin, *after)
require.NoError(t, err)
assert.Empty(t, changes)
assert.Len(t, repo.changes, 3)
// Clearing a limit removes the stored value, recorded as a change to nothing.
after.Point.MaxPerOrder = nil
changes, err = p.UpdateOutlet(ctx, org, outlet, admin, *after)
require.NoError(t, err)
require.Len(t, changes, 1)
assert.Equal(t, "0", *changes[0].OldValue)
assert.Nil(t, changes[0].NewValue)
assert.NotContains(t, repo.outletValues, constants.LoyaltyPointMaxPerOrderKey)
}
func TestLoyaltySettings_UpdateOrganization(t *testing.T) {
org, admin := uuid.New(), uuid.New()
repo := &loyaltyRepoFake{}
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
ctx := context.Background()
settings, err := p.Organization(ctx, org)
require.NoError(t, err)
settings.PointValue = 100
settings.CoinExpiry.Enabled = true
settings.CoinExpiry.Unit = constants.LoyaltyExpiryUnitDay
changes, err := p.UpdateOrganization(ctx, org, admin, *settings)
require.NoError(t, err)
keys := []string{}
for _, c := range changes {
keys = append(keys, c.Key)
assert.Nil(t, c.OutletID, "organization settings have no outlet")
}
assert.ElementsMatch(t, []string{constants.LoyaltyPointValueKey, "loyalty.coin.expiry_enabled", "loyalty.coin.expiry_unit"}, keys)
value, err := p.PointValue(ctx, org)
require.NoError(t, err)
assert.Equal(t, int64(100), value)
}
func TestLoyaltySettings_UpdateRejectsInvalidValues(t *testing.T) {
org, outlet := uuid.New(), uuid.New()
repo := &loyaltyRepoFake{outletOrg: map[uuid.UUID]uuid.UUID{outlet: org}}
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
ctx := context.Background()
for name, mutate := range map[string]func(*models.OutletLoyaltySettings){
"earn_per_amount 0": func(s *models.OutletLoyaltySettings) { s.Point.EarnPerAmount = 0 },
"negative earn_value": func(s *models.OutletLoyaltySettings) { s.Coin.EarnValue = -1 },
"negative min_order": func(s *models.OutletLoyaltySettings) { s.Point.MinOrderAmount = -1 },
"negative max_per_order": func(s *models.OutletLoyaltySettings) { s.Coin.MaxPerOrder = ptr(int64(-1)) },
"payment percent over 100": func(s *models.OutletLoyaltySettings) { s.PointPayment.MaxPaymentPercent = 101 },
"negative payment percent": func(s *models.OutletLoyaltySettings) { s.PointPayment.MaxPaymentPercent = -1 },
"min payment points 0": func(s *models.OutletLoyaltySettings) { s.PointPayment.MinPaymentPoints = 0 },
} {
s, err := p.Outlet(ctx, outlet)
require.NoError(t, err)
mutate(s)
_, err = p.UpdateOutlet(ctx, org, outlet, uuid.New(), *s)
assert.ErrorIs(t, err, ErrInvalidLoyaltySettings, name)
}
for name, mutate := range map[string]func(*models.OrganizationLoyaltySettings){
"point value 0": func(s *models.OrganizationLoyaltySettings) { s.PointValue = 0 },
"exchange coin amount 0": func(s *models.OrganizationLoyaltySettings) { s.Exchange.CoinAmount = 0 },
"transfer min 0": func(s *models.OrganizationLoyaltySettings) { s.Transfer.MinAmount = 0 },
"daily limit 0": func(s *models.OrganizationLoyaltySettings) { s.Transfer.DailyLimit = ptr(int64(0)) },
"expiry period 0": func(s *models.OrganizationLoyaltySettings) { s.PointExpiry.Period = 0 },
"expiry unit YEAR": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.Unit = "YEAR" },
"negative reminder days": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.ReminderDays = -1 },
} {
s, err := p.Organization(ctx, org)
require.NoError(t, err)
mutate(s)
_, err = p.UpdateOrganization(ctx, org, uuid.New(), *s)
assert.ErrorIs(t, err, ErrInvalidLoyaltySettings, name)
}
assert.Empty(t, repo.changes, "nothing is written when a value is invalid")
assert.Empty(t, repo.locks, "invalid input is rejected before touching the database")
// An outlet of another organization.
s, err := p.Outlet(ctx, outlet)
require.NoError(t, err)
_, err = p.UpdateOutlet(ctx, uuid.New(), outlet, uuid.New(), *s)
assert.ErrorIs(t, err, repository.ErrLoyaltyOutletNotFound)
_, err = p.UpdateOutlet(ctx, org, uuid.New(), uuid.New(), *s)
assert.ErrorIs(t, err, repository.ErrLoyaltyOutletNotFound)
}