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}, 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, 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, }, *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", "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) 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) 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", 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 }, "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)) }, "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 }, "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 }, } { 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") }