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") }