package sms import ( "context" "fmt" "sync" "github.com/zeromicro/go-zero/core/logx" ) // MockRedisHook is the minimal Redis surface needed to persist the last mock // SMS body for dev/k6 inspection. *github.com/zeromicro/go-zero/core/stores/redis.Redis // already satisfies it (SetexCtx). // // Production SMS senders never receive this hook; it is only wired when // SMSConfig.Provider=="mock" and a Redis client is available. type MockRedisHook interface { SetexCtx(ctx context.Context, key, value string, seconds int) error } // MockSender records calls and returns configurable results (for tests and local dev). type MockSender struct { name string sort int mu sync.Mutex calls []*Message Err error MessageID string SendHook func(ctx context.Context, msg *Message) (string, error) // Optional Redis hook for dev/k6: on every successful Send, the message // body is written to "dev:notification:last:sms:" with the given TTL. // nil hook → behaviour identical to original implementation. redis MockRedisHook redisKeyTTL int // seconds, defaults to 600 when redis hook set } type MockSenderOption func(*MockSender) func WithMockName(name string) MockSenderOption { return func(m *MockSender) { m.name = name } } func WithMockSort(sort int) MockSenderOption { return func(m *MockSender) { m.sort = sort } } func WithMockError(err error) MockSenderOption { return func(m *MockSender) { m.Err = err } } func WithMockMessageID(id string) MockSenderOption { return func(m *MockSender) { m.MessageID = id } } // WithMockRedis enables dev/k6 OTP inspection by mirroring every outbound // mock SMS body into Redis at key "dev:notification:last:sms:". // ttlSeconds <= 0 → defaults to 600 (10m). func WithMockRedis(r MockRedisHook, ttlSeconds int) MockSenderOption { return func(m *MockSender) { m.redis = r if ttlSeconds <= 0 { ttlSeconds = 600 } m.redisKeyTTL = ttlSeconds } } func NewMockSender(opts ...MockSenderOption) *MockSender { m := &MockSender{ name: "mock", sort: 0, MessageID: "mock-sms-id", redisKeyTTL: 600, } for _, opt := range opts { opt(m) } return m } func (m *MockSender) Name() string { return m.name } func (m *MockSender) Sort() int { return m.sort } // MockSMSRedisKeyPrefix is the key prefix written by the Redis hook. // Exposed so k6 / dev tooling can resolve the key for a given phone. const MockSMSRedisKeyPrefix = "dev:notification:last:sms:" func (m *MockSender) Send(ctx context.Context, msg *Message) (string, error) { m.mu.Lock() m.calls = append(m.calls, msg) m.mu.Unlock() if m.SendHook != nil { return m.SendHook(ctx, msg) } if m.Err != nil { return "", m.Err } if msg != nil { logx.Infof("[notification mock sms] to=%s recipient=%s body=%q message_id=%s", msg.PhoneNumber, msg.RecipientName, msg.Body, m.MessageID) if m.redis != nil && msg.PhoneNumber != "" { key := MockSMSRedisKeyPrefix + msg.PhoneNumber if err := m.redis.SetexCtx(ctx, key, msg.Body, m.redisKeyTTL); err != nil { logx.Errorf("[notification mock sms] redis hook setex %s: %v", key, err) } } } return m.MessageID, nil } func (m *MockSender) Calls() []*Message { m.mu.Lock() defer m.mu.Unlock() out := make([]*Message, len(m.calls)) copy(out, m.calls) return out } func (m *MockSender) Reset() { m.mu.Lock() defer m.mu.Unlock() m.calls = nil } // ErrSender is a Sender that always fails (helper for tests). func ErrSender(name string, sort int, err error) Sender { return &staticErrSender{name: name, sort: sort, err: err} } type staticErrSender struct { name string sort int err error } func (s *staticErrSender) Name() string { return s.name } func (s *staticErrSender) Sort() int { return s.sort } func (s *staticErrSender) Send(context.Context, *Message) (string, error) { if s.err != nil { return "", s.err } return "", fmt.Errorf("%s: send failed", s.name) }