Files
apskel-pos-backend/internal/processor/wallet_transfer_processor_test.go
efrilmandClaude Opus 5.5 bf9651e221 feat(loyalty): notify transfer recipients through FCM
The recipient of a transfer now gets a push through FCM instead of a
WhatsApp message (docs/prd-point-coin.md F5).

Customers had nowhere to keep FCM tokens: user_devices only holds staff
devices. Migration 000096 adds customer_devices, and the customer app
registers with PUT /customer/devices { device_id, fcm_token, platform,
app_version } after login and whenever FCM refreshes the token, and
unregisters with DELETE /customer/devices/:device_id on logout. A token
belongs to one customer only: registering it takes it away from whoever
had it on that phone before, so they stop getting this customer's
notifications.

The push goes to every device of the recipient after the commit, titled
"EnakPoint masuk" or "EnakCoin masuk", with type WALLET_TRANSFER_IN, the
TRANSFER_IN transaction id, the group id, the currency and the amount in
its data so the app can open it. A retried transfer sends nothing again. It
stays best effort: no device, FCM not configured or FCM failing is logged
and never undoes the transfer.

The app builds one FCM client and shares it between staff notifications
and customer pushes.

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

244 lines
9.5 KiB
Go

package processor
import (
"context"
"errors"
"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/models"
"apskel-pos-be/internal/repository"
)
type pushFake struct {
title, body string
data map[string]string
}
// notifierFake records the pushes each customer would get.
type notifierFake struct{ pushes map[uuid.UUID][]pushFake }
func (f *notifierFake) Notify(_ context.Context, customerID uuid.UUID, title, body string, data map[string]string) error {
if f.pushes == nil {
f.pushes = map[uuid.UUID][]pushFake{}
}
f.pushes[customerID] = append(f.pushes[customerID], pushFake{title: title, body: body, data: data})
return nil
}
func sendPoints(amount int64, phone string) models.WalletTransfer {
return models.WalletTransfer{Currency: constants.WalletCurrencyPoint, Amount: amount, RecipientPhone: phone}
}
func TestWalletTransfer_MovesBalanceWithItsExpiry(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
b := e.member("Budi Santoso", "081234561234")
dec, jan := e.at(30*24*time.Hour), e.at(60*24*time.Hour)
first := e.credit(t, earn(a, 100, dec))
second := e.credit(t, earn(a, 50, jan))
notifier := &notifierFake{}
// The example in ยง8: A sends 120, 100 from the lot expiring first and 20 from the next.
res, err := e.transfers(notifier).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
assert.Equal(t, int64(30), res.Balance)
assert.Equal(t, models.WalletTransferRecipient{Name: "Bu*** Sa***", PhoneNumber: "08**-****-1234"}, res.Recipient)
assert.Equal(t, []models.WalletMovedLot{{Amount: 100, ExpiresAt: dec}, {Amount: 20, ExpiresAt: jan}}, res.Lots)
assert.Equal(t, int64(120), e.balance(t, b))
assert.Equal(t, []PinAction{PinActionTransfer}, e.pins.actions)
var bLots []uuid.UUID
for _, lot := range e.repo.lots {
if lot.CustomerID == b {
require.NotNil(t, lot.OriginLotID)
bLots = append(bLots, *lot.OriginLotID)
for _, origin := range e.repo.lots {
if origin.ID == *lot.OriginLotID {
assert.Equal(t, origin.ExpiresAt, lot.ExpiresAt, "the recipient's lot expires exactly when the sender's did")
}
}
}
}
assert.Equal(t, []uuid.UUID{first.Lots[0].ID, second.Lots[0].ID}, bLots)
out, in := e.repo.transactions[2], e.repo.transactions[3]
assert.Equal(t, constants.WalletTxTypeTransferOut, out.Type)
assert.Equal(t, b, *out.CounterpartyCustomerID)
assert.Equal(t, in.ID, out.ReferenceID)
assert.Equal(t, "Transfer ke Bu*** Sa*** (08**-****-1234)", out.Description)
assert.Equal(t, constants.WalletTxTypeTransferIn, in.Type)
assert.Equal(t, a, *in.CounterpartyCustomerID)
assert.Equal(t, out.ID, in.ReferenceID)
assert.Equal(t, *out.GroupID, *in.GroupID)
assert.Equal(t, "Transfer dari An*** (08**-****-5678)", in.Description)
assert.GreaterOrEqual(t, e.repo.locks[a], 1)
assert.GreaterOrEqual(t, e.repo.locks[b], 1)
assert.Equal(t, []pushFake{{
title: "EnakPoint masuk",
body: "Kamu menerima 120 EnakPoint dari An*** (08**-****-5678).",
data: map[string]string{
"type": NotificationTypeWalletTransferIn,
"transaction_id": in.ID.String(),
"group_id": in.GroupID.String(),
"currency": constants.WalletCurrencyPoint,
"amount": "120",
},
}}, notifier.pushes[b])
assert.Empty(t, notifier.pushes[a], "the sender gets no push")
}
func TestWalletTransfer_Coins(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
b := e.member("Budi", "081234561234")
e.earnCoins(t, a, 10, nil)
_, err := e.transfers(nil).Transfer(e.ctx, a, models.WalletTransfer{Currency: "coin", Amount: 4, RecipientPhone: "081234561234"}, "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
assert.Equal(t, int64(6), e.coinBalance(t, a))
assert.Equal(t, int64(4), e.coinBalance(t, b))
}
func TestWalletTransfer_RefusesRecipientsItMayNotSendTo(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
e.credit(t, earn(a, 100, nil))
walkIn := e.member("Walk-in", "081100000000")
e.customers.byID[walkIn].IsDefault = true
inactive := e.member("Old", "081100000001")
e.customers.byID[inactive].IsActive = false
elsewhere := e.member("Other Org", "081100000002")
e.customers.byID[elsewhere].OrganizationID = uuid.New()
for phone, want := range map[string]error{
"081200005678": ErrWalletMoveRejected, // herself
"081100000000": ErrWalletMoveRejected, // the walk-in customer
"081100000001": ErrWalletMoveRejected, // inactive
"081100000002": ErrWalletRecipientNotFound, // another organization looks like nobody
"081999999999": ErrWalletRecipientNotFound,
"": ErrWalletMoveRejected,
} {
_, err := e.transfers(nil).Recipient(e.ctx, a, phone)
assert.ErrorIs(t, err, want, phone)
_, err = e.transfers(nil).Transfer(e.ctx, a, sendPoints(10, phone), "482913", "key-"+phone, models.CustomerPinRequestInfo{})
assert.ErrorIs(t, err, want, phone)
}
assert.Empty(t, e.pins.actions, "refused before the PIN")
assert.Equal(t, int64(100), e.balance(t, a))
}
func TestWalletTransfer_RecipientIsMasked(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
e.member("Budi Santoso", "081234561234")
got, err := e.transfers(nil).Recipient(e.ctx, a, " 081234561234 ")
require.NoError(t, err)
assert.Equal(t, &models.WalletTransferRecipient{Name: "Bu*** Sa***", PhoneNumber: "08**-****-1234"}, got)
}
func TestWalletTransfer_OrganizationLimits(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
e.member("Budi", "081234561234")
e.credit(t, earn(a, 1000, nil))
e.settings.Transfer = models.LoyaltyTransferSettings{Enabled: true, MinAmount: 10, MaxPerTransaction: ptr(int64(300)), DailyLimit: ptr(int64(500))}
send := func(amount int64, key string) error {
_, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(amount, "081234561234"), "482913", key, models.CustomerPinRequestInfo{})
return err
}
assert.ErrorIs(t, send(9, "below-min"), ErrWalletMoveRejected)
assert.ErrorIs(t, send(301, "above-max"), ErrWalletMoveRejected)
require.NoError(t, send(300, "k1"))
require.NoError(t, send(200, "k2"))
// The daily limit is used up; a retry of a transfer already made still replays.
assert.ErrorIs(t, send(10, "k3"), ErrWalletMoveRejected)
require.NoError(t, send(200, "k2"))
// It starts over the next day in the customer's time zone.
e.now = startOfWalletDay(e.now).AddDate(0, 0, 1).Add(time.Minute)
e.repo.clock = e.now
require.NoError(t, send(10, "k4"))
assert.Equal(t, int64(490), e.balance(t, a))
e.settings.Transfer.Enabled = false
assert.ErrorIs(t, send(10, "k5"), ErrWalletMoveRejected)
}
func TestWalletTransfer_HeldAfterPinReset(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
e.member("Budi", "081234561234")
e.credit(t, earn(a, 100, nil))
until := e.now.Add(time.Hour)
e.pins.err = &PinError{Code: PinErrTransferBlocked, Until: &until}
_, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(10, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
var pinErr *PinError
require.True(t, errors.As(err, &pinErr))
assert.Equal(t, PinErrTransferBlocked, pinErr.Code)
assert.Equal(t, int64(100), e.balance(t, a))
}
func TestWalletTransfer_NotEnoughBalance(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
b := e.member("Budi", "081234561234")
e.credit(t, earn(a, 100, nil))
// An expired lot cannot be sent even before the expiry job takes it.
e.credit(t, earn(a, 50, e.at(-time.Hour)))
_, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
assert.ErrorIs(t, err, ErrWalletMoveRejected)
assert.Equal(t, int64(0), e.balance(t, b))
}
func TestWalletTransfer_RetryMovesNothingAndTellsNobodyAgain(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
b := e.member("Budi", "081234561234")
e.member("Citra", "081255550000")
e.credit(t, earn(a, 100, nil))
notifier := &notifierFake{}
first, err := e.transfers(notifier).Transfer(e.ctx, a, sendPoints(40, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
again, err := e.transfers(notifier).Transfer(e.ctx, a, sendPoints(40, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
assert.True(t, again.Replayed)
assert.Equal(t, first.GroupID, again.GroupID)
assert.Equal(t, int64(40), e.balance(t, b))
assert.Len(t, notifier.pushes[b], 1)
// The same key to someone else is not a retry.
_, err = e.transfers(notifier).Transfer(e.ctx, a, sendPoints(40, "081255550000"), "482913", "key-1", models.CustomerPinRequestInfo{})
assert.ErrorIs(t, err, ErrWalletIdempotencyConflict)
}
func TestWalletTransfer_UnknownSender(t *testing.T) {
e := newWalletMoveEnv(t)
_, err := e.transfers(nil).Transfer(e.ctx, uuid.New(), sendPoints(1, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
assert.ErrorIs(t, err, repository.ErrWalletNotFound)
}
func TestMaskName(t *testing.T) {
assert.Equal(t, "Bu*** Sa***", maskName("Budi Santoso"))
assert.Equal(t, "An***", maskName("Anita"))
assert.Equal(t, "A*** Ra***", maskName("Al Rahman"))
assert.Equal(t, "***", maskName(" "))
assert.Equal(t, "08**-****-1234", maskPhoneNumber("081234561234"))
assert.Equal(t, "+6**-****-1234", maskPhoneNumber("+6281234561234"))
assert.Equal(t, "****", maskPhoneNumber("12345"))
}