Files
apskel-pos-backend/internal/processor/wallet_expiry_processor_test.go
T

245 lines
8.3 KiB
Go
Raw Normal View History

package processor
import (
"context"
"sort"
"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/repository"
)
// walletExpiryRepoFake lists the fake wallet's due lots the way the query does, plus
// any extra lots a test wants listed.
type walletExpiryRepoFake struct {
wallet *walletRepoFake
extra []repository.DueLot
// customer/currency/date of the reminders recorded.
reminded map[string]bool
}
func (f *walletExpiryRepoFake) ListDueLots(_ context.Context, asOf time.Time, limit int) ([]repository.DueLot, error) {
descriptions := map[uuid.UUID]string{}
for _, tx := range f.wallet.transactions {
descriptions[tx.ID] = tx.Description
}
due := append([]repository.DueLot(nil), f.extra...)
for _, lot := range f.wallet.lots {
if lot.RemainingAmount > 0 && lot.ExpiresAt != nil && !lot.ExpiresAt.After(asOf) {
due = append(due, repository.DueLot{
ID: lot.ID, CustomerID: lot.CustomerID, Currency: lot.Currency, Remaining: lot.RemainingAmount,
ExpiresAt: *lot.ExpiresAt, SourceDescription: descriptions[lot.SourceTransactionID],
})
}
}
sort.SliceStable(due, func(i, j int) bool { return due[i].ExpiresAt.Before(due[j].ExpiresAt) })
if len(due) > limit {
due = due[:limit]
}
return due, nil
}
func (e *walletMoveEnv) expiry(notifier customerNotifier) (*WalletExpiryProcessor, *walletExpiryRepoFake) {
repo := &walletExpiryRepoFake{wallet: e.repo}
p := NewWalletExpiryProcessor(repo, e, e.p, txRunnerFake{}, notifier)
p.now = func() time.Time { return e.now }
return p, repo
}
func TestWalletExpiry_ExpiresWhatIsDueAndTellsTheCustomer(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
ord := earn(a, 150, e.at(-time.Hour))
ord.Description = "Belanja #ORD-0098"
due := e.credit(t, ord)
e.credit(t, earn(a, 50, e.at(-2*time.Hour)))
e.credit(t, earn(a, 70, e.at(time.Hour))) // not yet
e.credit(t, earn(a, 30, nil)) // never
e.earnCoins(t, a, 4, e.at(-time.Minute))
// A payment already used part of the first lot; only the rest expires.
_, err := e.p.Debit(e.ctx, WalletDebitInput{WalletEntry: pay(a, 20).WalletEntry, PreferredLotIDs: []uuid.UUID{due.Lots[0].ID}})
require.NoError(t, err)
notifier := &notifierFake{}
p, _ := e.expiry(notifier)
count, err := p.ExpireDue(e.ctx)
require.NoError(t, err)
assert.Equal(t, 3, count)
assert.Equal(t, int64(100), e.balance(t, a), "70 not due yet + 30 that never expires")
assert.Equal(t, int64(0), e.coinBalance(t, a))
var expire *entities.WalletTransaction
for _, tx := range e.repo.transactions {
if tx.Type == constants.WalletTxTypeExpire && tx.ReferenceID == due.Lots[0].ID {
expire = tx
}
}
require.NotNil(t, expire)
assert.Equal(t, int64(-130), expire.Amount)
assert.Equal(t, "Kedaluwarsa: 130 EnakPoint dari Belanja #ORD-0098", expire.Description)
assert.Equal(t, constants.WalletRefTypeLot, expire.ReferenceType)
assert.Equal(t, "expire:"+due.Lots[0].ID.String(), *expire.IdempotencyKey)
// One push per currency, with the total.
pushes := notifier.pushes[a]
require.Len(t, pushes, 2)
byCurrency := map[string]pushFake{}
for _, p := range pushes {
byCurrency[p.data["currency"]] = p
}
assert.Equal(t, "EnakPoint kedaluwarsa", byCurrency["POINT"].title)
assert.Equal(t, "180 EnakPoint kamu sudah kedaluwarsa.", byCurrency["POINT"].body)
assert.Equal(t, NotificationTypeWalletExpired, byCurrency["POINT"].data["type"])
assert.Equal(t, "4", byCurrency["COIN"].data["amount"])
}
func TestWalletExpiry_RunningAgainExpiresNothingMore(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
e.credit(t, earn(a, 150, e.at(-time.Hour)))
notifier := &notifierFake{}
// Two instances, one after the other.
first, _ := e.expiry(notifier)
second, _ := e.expiry(notifier)
n1, err := first.ExpireDue(e.ctx)
require.NoError(t, err)
n2, err := second.ExpireDue(e.ctx)
require.NoError(t, err)
assert.Equal(t, 1, n1)
assert.Equal(t, 0, n2)
assert.Len(t, notifier.pushes[a], 1)
var expires int
for _, tx := range e.repo.transactions {
if tx.Type == constants.WalletTxTypeExpire {
expires++
}
}
assert.Equal(t, 1, expires)
}
func TestWalletExpiry_OneFailingLotDoesNotStopTheOthers(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
e.credit(t, earn(a, 150, e.at(-time.Hour)))
p, repo := e.expiry(nil)
// A lot listed that ExpireLot cannot find.
repo.extra = []repository.DueLot{{ID: uuid.New(), CustomerID: a, Currency: "POINT", Remaining: 5, ExpiresAt: e.now.Add(-3 * time.Hour)}}
count, err := p.ExpireDue(e.ctx)
require.NoError(t, err)
assert.Equal(t, 1, count)
assert.Equal(t, int64(0), e.balance(t, a))
}
func TestWalletExpiry_NothingDue(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
e.credit(t, earn(a, 150, e.at(time.Hour)))
notifier := &notifierFake{}
p, _ := e.expiry(notifier)
count, err := p.ExpireDue(e.ctx)
require.NoError(t, err)
assert.Zero(t, count)
assert.Empty(t, notifier.pushes)
}
func (f *walletExpiryRepoFake) OrganizationsWithUpcomingExpiry(_ context.Context, asOf time.Time) ([]uuid.UUID, error) {
seen := map[uuid.UUID]bool{}
var out []uuid.UUID
for _, lot := range f.wallet.lots {
if lot.RemainingAmount > 0 && lot.ExpiresAt != nil && lot.ExpiresAt.After(asOf) && !seen[lot.OrganizationID] {
seen[lot.OrganizationID] = true
out = append(out, lot.OrganizationID)
}
}
return out, nil
}
func (f *walletExpiryRepoFake) UpcomingUnreminded(_ context.Context, organizationID uuid.UUID, currency string, asOf, until time.Time) ([]repository.UpcomingExpiry, error) {
sums := map[[2]string]int64{}
var order [][2]string
for _, lot := range f.wallet.lots {
if lot.OrganizationID != organizationID || lot.Currency != currency || lot.RemainingAmount == 0 ||
lot.ExpiresAt == nil || !lot.ExpiresAt.After(asOf) || lot.ExpiresAt.After(until) {
continue
}
key := [2]string{lot.CustomerID.String(), lot.ExpiresAt.In(walletDisplayLocation).Format("2006-01-02")}
if f.reminded[key[0]+"/"+currency+"/"+key[1]] {
continue
}
if _, ok := sums[key]; !ok {
order = append(order, key)
}
sums[key] += lot.RemainingAmount
}
var out []repository.UpcomingExpiry
for _, key := range order {
out = append(out, repository.UpcomingExpiry{CustomerID: uuid.MustParse(key[0]), Date: key[1], Amount: sums[key]})
}
return out, nil
}
func (f *walletExpiryRepoFake) MarkReminded(_ context.Context, u repository.UpcomingExpiry, currency string) (bool, error) {
if f.reminded == nil {
f.reminded = map[string]bool{}
}
key := u.CustomerID.String() + "/" + currency + "/" + u.Date
if f.reminded[key] {
return false, nil
}
f.reminded[key] = true
return true, nil
}
func TestWalletExpiry_RemindsOncePerDayBeforeExpiry(t *testing.T) {
e := newWalletMoveEnv(t)
e.now = wib(2026, 10, 25, 9, 0)
e.settings.PointExpiry.ReminderDays = 7
e.settings.CoinExpiry.ReminderDays = 0 // no reminders for EnakCoin
a := e.member("Anita", "081200005678")
oct31 := wib(2026, 10, 31, 23, 59)
nov30 := wib(2026, 11, 30, 23, 59)
e.credit(t, earn(a, 100, &oct31))
e.credit(t, earn(a, 50, &oct31))
e.credit(t, earn(a, 70, &nov30)) // too far off yet
e.earnCoins(t, a, 5, &oct31)
notifier := &notifierFake{}
p, _ := e.expiry(notifier)
sent, err := p.SendReminders(e.ctx)
require.NoError(t, err)
assert.Equal(t, 1, sent)
require.Len(t, notifier.pushes[a], 1)
push := notifier.pushes[a][0]
assert.Equal(t, "EnakPoint akan kedaluwarsa", push.title)
assert.Equal(t, "150 EnakPoint akan kedaluwarsa pada 31 Okt 2026. Pakai sebelum hangus.", push.body)
assert.Equal(t, map[string]string{"type": NotificationTypeWalletExpiring, "currency": "POINT", "amount": "150", "expiry_date": "2026-10-31"}, push.data)
// The next run, on this instance or another, sends nothing again.
again, err := p.SendReminders(e.ctx)
require.NoError(t, err)
assert.Zero(t, again)
// Once 30 Nov comes within seven days, it gets its own reminder.
e.now = wib(2026, 11, 23, 9, 0)
sent, err = p.SendReminders(e.ctx)
require.NoError(t, err)
assert.Equal(t, 1, sent)
assert.Equal(t, "70", notifier.pushes[a][1].data["amount"])
}
func TestFormatWalletDate(t *testing.T) {
assert.Equal(t, "31 Okt 2026", formatWalletDate("2026-10-31"))
assert.Equal(t, "1 Mei 2027", formatWalletDate("2027-05-01"))
}