Files
apskel-pos-backend/internal/processor/loyalty_settings_processor_test.go
efrilmandClaude Opus 5.5 798a36bd6c feat(enakgame): game sessions, rewards, vouchers, budgets and events
EnakGame phases 1-8 of docs/tasks-enakgame.md (EG-101 to EG-803), built on the
existing EnakPoint/EnakCoin wallet (docs/rfc-enakgame.md).

Foundation (phase 1)
- Migrations 000103-000106: games extended with organization, slug, status,
  entry cost and result rules, old games archived (not deleted); budgets,
  versioned reward configs, sessions and session rewards; the ledger types
  GAME_SPEND_REFUND, GAME_REWARD and REWARD_REDEEM_REFUND; audit_logs.
- AuditLogger writes in the caller's transaction only.
- enakgame.limit.user_daily and global_daily organization settings.

Games and sessions (phases 2-4)
- Admin /marketing/enakgame: games, reward config versions (immutable but for
  status, one ACTIVE per game), budgets with non-overlapping global periods and
  a daily job opening the next month.
- Customer /customer/enakgame: start (Idempotency-Key, entry cost and config
  frozen on the session), complete (result validation, reward engine, max_reward
  cap, daily limits via game_reward_counters, one GAME_REWARD per budget),
  automatic refunds for system errors and deactivated games, and a session job.
- Reward engine: FIXED, SCORE_BASED, OUTCOME_BASED, PROBABILITY (crypto/rand),
  rounded down.

Vouchers and budgets (phases 5-6)
- Migration 000108 and 000107: vouchers, codes, redemptions, cost attribution;
  Economy Guard counters.
- STATIC and CODE_POOL redemption in one transaction with the REDEEM PIN action;
  realized cost traced through the lots to the budget that paid the reward.
- Budget metrics: realized cost, forecast, exposure and status. Migrations
  000109-000110 add the wallet_lots indexes they need, built CONCURRENTLY.

Events (phase 7)
- Migration 000111: game events, each with its own EVENT budget. Event extras
  stack per PRD §16 defaults, with event and per-customer limits.

External vouchers (phase 8)
- VoucherProvider contract, two-step PENDING redemption and a recovery job,
  tested with a fake provider. No provider adapter is registered yet, so
  EXTERNAL vouchers stay out of the catalog.

Not yet decided before release: reward rounding, event stacking, budget
exhaustion policy and thresholds (RFC §19.2). Migrations 000103-000111 have
not been run on any shared database.

Also fixes a leftover PAYMENT filter in a wallet test and a data race in a
test PIN fake.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:53:14 +07:00

389 lines
16 KiB
Go

package processor
import (
"context"
"os"
"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/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
// Lots without an expiry per currency: how many and what they hold.
undated map[string][2]int64
dated map[string]time.Time
}
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, EarnMode: "PER_AMOUNT", EarnPerAmount: 100, EarnValue: 1, EarnPercent: 1, MinOrderAmount: 0, MaxPerOrder: nil},
Coin: models.LoyaltyEarnSettings{Enabled: false, EarnMode: "PER_AMOUNT", EarnPerAmount: 25000, EarnValue: 1, EarnPercent: 1, MinOrderAmount: 0, MaxPerOrder: nil},
}, *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, Mode: "FIXED_DATE", FixedDates: []string{"12-31"}, GraceMonths: 3, 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,
EnakGame: models.EnakGameLimitSettings{UserDailyLimit: 0, GlobalDailyLimit: 0},
}, *s)
}
func TestLoyaltySettings_StoredValuesAreTyped(t *testing.T) {
repo := &loyaltyRepoFake{
outletValues: map[string]string{
constants.LoyaltyPointEnabledKey: "true",
constants.LoyaltyPointEarnPerAmountKey: " 1000 ",
constants.LoyaltyPointMaxPerOrderKey: "500",
"loyalty.unknown": "ignored",
},
orgValues: map[string]string{
constants.LoyaltyPointValueKey: "100",
constants.LoyaltyTransferDailyLimitKey: "10000",
"loyalty.coin.expiry_unit": "day",
"loyalty.point.expiry_fixed_dates": "12-31, 06-30,12-31",
"loyalty.coin.expiry_mode": "rolling",
},
}
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)
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)
assert.Equal(t, []string{"06-30", "12-31"}, org.PointExpiry.FixedDates)
assert.Equal(t, "ROLLING", org.CoinExpiry.Mode)
assert.Equal(t, "FIXED_DATE", org.PointExpiry.Mode)
}
// 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",
},
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)
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
settings.EnakGame.UserDailyLimit = 50
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", "enakgame.limit.user_daily"}, keys)
value, err := p.PointValue(ctx, org)
require.NoError(t, err)
assert.Equal(t, int64(100), value)
saved, err := p.Organization(ctx, org)
require.NoError(t, err)
assert.Equal(t, models.EnakGameLimitSettings{UserDailyLimit: 50, GlobalDailyLimit: 0}, saved.EnakGame)
}
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 },
"earn mode unknown": func(s *models.OutletLoyaltySettings) { s.Point.EarnMode = "PERCENT" },
"negative earn_percent": func(s *models.OutletLoyaltySettings) { s.Point.EarnPercent = -1 },
"earn_percent over 100": func(s *models.OutletLoyaltySettings) { s.Coin.EarnPercent = 100.5 },
"earn_percent 3 decimals": func(s *models.OutletLoyaltySettings) { s.Point.EarnPercent = 1.125 },
"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)) },
} {
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 },
"expiry mode YEARLY": func(s *models.OrganizationLoyaltySettings) { s.PointExpiry.Mode = "YEARLY" },
"no fixed dates": func(s *models.OrganizationLoyaltySettings) { s.PointExpiry.FixedDates = nil },
"fixed date 13-01": func(s *models.OrganizationLoyaltySettings) { s.PointExpiry.FixedDates = []string{"13-01"} },
"fixed date 02-29": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.FixedDates = []string{"02-29"} },
"negative grace": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.GraceMonths = -1 },
"negative user daily": func(s *models.OrganizationLoyaltySettings) { s.EnakGame.UserDailyLimit = -1 },
"negative global daily": func(s *models.OrganizationLoyaltySettings) { s.EnakGame.GlobalDailyLimit = -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)
}
func (f *loyaltyRepoFake) UndatedBalance(_ context.Context, _ uuid.UUID, currency string) (int64, int64, error) {
u := f.undated[currency]
return u[0], u[1], nil
}
func (f *loyaltyRepoFake) DateUndatedLots(_ context.Context, _ uuid.UUID, currency string, expiresAt time.Time) (int64, int64, error) {
u := f.undated[currency]
if f.dated == nil {
f.dated = map[string]time.Time{}
}
f.dated[currency] = expiresAt
delete(f.undated, currency)
return u[0], u[1], nil
}
// Turning expiry on dates the balances that had none, a full cycle away; a dry run
// only counts them, and saving again dates nothing more.
func TestLoyaltySettings_TurningExpiryOnDatesExistingBalances(t *testing.T) {
org, admin := uuid.New(), uuid.New()
repo := &loyaltyRepoFake{undated: map[string][2]int64{"POINT": {3, 1500}, "COIN": {2, 40}}}
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
p.now = func() time.Time { return time.Date(2026, 11, 20, 3, 0, 0, 0, time.UTC) }
ctx := context.Background()
settings, err := p.Organization(ctx, org)
require.NoError(t, err)
settings.PointExpiry.Enabled = true
_, preview, err := p.PreviewOrganization(ctx, org, *settings)
require.NoError(t, err)
require.Len(t, preview, 1)
assert.Equal(t, "POINT", preview[0].Currency)
assert.Equal(t, int64(1500), preview[0].Amount)
assert.Empty(t, repo.dated, "a dry run dates nothing")
_, activations, err := p.UpdateOrganization(ctx, org, admin, *settings)
require.NoError(t, err)
require.Len(t, activations, 1)
assert.Equal(t, int64(3), activations[0].Lots)
// Default FIXED_DATE on 31 Dec: the second one after 20 Nov 2026.
assert.Equal(t, "2027-12-31", activations[0].ExpiresAt.In(walletDisplayLocation).Format("2006-01-02"))
assert.Equal(t, activations[0].ExpiresAt, repo.dated["POINT"])
assert.NotContains(t, repo.dated, "COIN", "EnakCoin expiry stays off")
_, again, err := p.UpdateOrganization(ctx, org, admin, *settings)
require.NoError(t, err)
assert.Empty(t, again, "already on: nothing is activated twice")
}