feat(loyalty): one-time EnakPoint payment code
Adds POST /customer/wallet/payment-code (docs/prd-point-coin.md F9, K8, PC-304). The customer approves with their PIN on their own phone and gets a 6-digit code, as digits and as a QR payload (enakpoint:<code>) for the app to render, valid for two minutes. The PIN is never typed at the cashier. Codes are drawn from crypto/rand and stored in Redis with SET NX and a TTL, bound to the customer; a new code retires the previous one. Redeeming is a single Lua step that uses the code up only if it belongs to the order's customer, so it stays one-time under a race, and a cashier scanning it against the wrong order does not burn it for its owner, which a plain GETDEL would. Expired, used, unknown and other customers' codes are all refused alike. Tests run against miniredis, added as a test dependency. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
cf5332c281
commit
0c4dd72583
@@ -0,0 +1,136 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
type pinVerifierFake struct{ good string }
|
||||
|
||||
func (f pinVerifierFake) VerifyPin(_ context.Context, _ uuid.UUID, pin string, action PinAction, _ models.CustomerPinRequestInfo) error {
|
||||
if action != PinActionPay {
|
||||
return &PinError{Code: "UNEXPECTED_ACTION"}
|
||||
}
|
||||
if pin != f.good {
|
||||
return &PinError{Code: PinErrInvalid, RemainingAttempts: 4}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newPaymentCodeTest(t *testing.T) (*PaymentCodeProcessor, *miniredis.Miniredis) {
|
||||
t.Helper()
|
||||
mr := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { client.Close() })
|
||||
return NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(client), pinVerifierFake{good: "482913"}), mr
|
||||
}
|
||||
|
||||
func TestPaymentCode_IssueNeedsThePin(t *testing.T) {
|
||||
p, mr := newPaymentCodeTest(t)
|
||||
_, err := p.Issue(context.Background(), uuid.New(), "000000", models.CustomerPinRequestInfo{})
|
||||
var pe *PinError
|
||||
require.ErrorAs(t, err, &pe)
|
||||
assert.Equal(t, PinErrInvalid, pe.Code)
|
||||
assert.Empty(t, mr.Keys(), "nothing is issued without the PIN")
|
||||
}
|
||||
|
||||
func TestPaymentCode_Lifecycle(t *testing.T) {
|
||||
p, mr := newPaymentCodeTest(t)
|
||||
ctx := context.Background()
|
||||
customer, other := uuid.New(), uuid.New()
|
||||
|
||||
code, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, code.Code, 6)
|
||||
assert.Equal(t, "enakpoint:"+code.Code, code.QRPayload)
|
||||
assert.WithinDuration(t, time.Now().Add(2*time.Minute), code.ExpiresAt, 2*time.Second)
|
||||
assert.InDelta(t, 120, mr.TTL("wallet:paycode:"+code.Code).Seconds(), 1, "Redis expires it by itself")
|
||||
|
||||
// A code of another customer is refused, and stays usable by its owner.
|
||||
assert.ErrorIs(t, p.Redeem(ctx, code.Code, other), ErrPaymentCodeInvalid)
|
||||
// Scanned from the QR it works; used once, it is gone.
|
||||
require.NoError(t, p.Redeem(ctx, code.QRPayload, customer))
|
||||
assert.ErrorIs(t, p.Redeem(ctx, code.Code, customer), ErrPaymentCodeInvalid)
|
||||
|
||||
// An expired code is refused.
|
||||
late, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
mr.FastForward(2*time.Minute + time.Second)
|
||||
assert.ErrorIs(t, p.Redeem(ctx, late.Code, customer), ErrPaymentCodeInvalid)
|
||||
|
||||
// A new code retires the previous one.
|
||||
first, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
second, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
if first.Code != second.Code {
|
||||
assert.ErrorIs(t, p.Redeem(ctx, first.Code, customer), ErrPaymentCodeInvalid)
|
||||
}
|
||||
require.NoError(t, p.Redeem(ctx, second.Code, customer))
|
||||
|
||||
// Garbage is refused without touching Redis.
|
||||
for _, bad := range []string{"", "12345", "1234567", "enakpoint:"} {
|
||||
assert.ErrorIs(t, p.Redeem(ctx, bad, customer), ErrPaymentCodeInvalid, bad)
|
||||
}
|
||||
}
|
||||
|
||||
// Two cashiers scanning the same code at once: exactly one gets it.
|
||||
func TestPaymentCode_UsedOnceUnderRace(t *testing.T) {
|
||||
p, _ := newPaymentCodeTest(t)
|
||||
ctx := context.Background()
|
||||
customer := uuid.New()
|
||||
code, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
|
||||
var wins int32
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if p.Redeem(ctx, code.Code, customer) == nil {
|
||||
atomic.AddInt32(&wins, 1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
assert.Equal(t, int32(1), wins)
|
||||
}
|
||||
|
||||
func TestPaymentCode_SaveRefusesALiveCode(t *testing.T) {
|
||||
mr := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
defer client.Close()
|
||||
repo := repository.NewPaymentCodeRepository(client)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, repo.Save(ctx, "123456", uuid.New(), time.Minute))
|
||||
assert.ErrorIs(t, repo.Save(ctx, "123456", uuid.New(), time.Minute), repository.ErrPaymentCodeTaken,
|
||||
"a live code is never handed to a second customer")
|
||||
}
|
||||
|
||||
func TestRandomDigits(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 200; i++ {
|
||||
d, err := randomDigits(6)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, d, 6)
|
||||
for _, r := range d {
|
||||
require.True(t, r >= '0' && r <= '9')
|
||||
}
|
||||
seen[d] = true
|
||||
}
|
||||
assert.Greater(t, len(seen), 190, "codes do not repeat")
|
||||
}
|
||||
Reference in New Issue
Block a user