// Command notify-test runs one notification test by -method (point-and-shoot). // // make deps-up && make mongo-index // make notify-test METHOD=email-send TO=you@example.com // make notify-test METHOD=sms-send PHONE=0912345678 MOCK=1 package main import ( "context" "flag" "fmt" "os" "strings" "time" "gateway/internal/config" redislib "gateway/internal/library/redis" memberenum "gateway/internal/model/member/domain/enum" dommember "gateway/internal/model/member/domain/usecase" memberusecase "gateway/internal/model/member/usecase" notifconfig "gateway/internal/model/notification/config" "gateway/internal/model/notification/domain/enum" domtpl "gateway/internal/model/notification/domain/template" domusecase "gateway/internal/model/notification/domain/usecase" notifusecase "gateway/internal/model/notification/usecase" "github.com/google/uuid" "github.com/zeromicro/go-zero/core/conf" ) const ( methodEmailSend = "email-send" methodEmailEnqueue = "email-enqueue" methodEmailIdempotency = "email-idempotency" methodSMSSend = "sms-send" methodSMSEnqueue = "sms-enqueue" methodMemberEmail = "member-email" methodMemberPhone = "member-phone" methodAdminDLQ = "admin-dlq" ) var validMethods = []string{ methodEmailSend, methodEmailEnqueue, methodEmailIdempotency, methodSMSSend, methodSMSEnqueue, methodMemberEmail, methodMemberPhone, methodAdminDLQ, } var ( configFile = flag.String("f", "etc/gateway.dev.yaml", "config file") method = flag.String("method", "", "test method (required): "+strings.Join(validMethods, ", ")) toEmail = flag.String("to", "", "recipient email") phone = flag.String("phone", "", "recipient phone") tenantID = flag.String("tenant", "notify-test", "tenant_id") uid = flag.String("uid", "notify-test-uid", "uid") mockOnly = flag.Bool("mock", false, "force mock email/SMS providers") pollSec = flag.Int("poll", 45, "max seconds to wait for async delivery (enqueue methods)") ) type env struct { ctx context.Context tenant string uid string to string phone string locale string notifier domusecase.NotifierUseCase // otp is the atomic primitive; this CLI plays the role of the future // logic layer and orchestrates OTP.Generate + Notifier.Send inline. otp dommember.OTPUseCase admin domusecase.AdminNotifierUseCase } func main() { flag.Usage = func() { fmt.Fprintf(os.Stderr, "Usage: notify-test -method [options]\n\n") fmt.Fprintf(os.Stderr, "Methods:\n") for _, m := range validMethods { fmt.Fprintf(os.Stderr, " %s\n", m) } fmt.Fprintf(os.Stderr, "\nExamples:\n") fmt.Fprintf(os.Stderr, " notify-test -method email-send -to you@example.com\n") fmt.Fprintf(os.Stderr, " notify-test -method email-enqueue -to you@example.com\n") fmt.Fprintf(os.Stderr, " notify-test -method sms-send -phone 0912345678\n") fmt.Fprintf(os.Stderr, " notify-test -method member-email -to you@example.com\n") fmt.Fprintf(os.Stderr, " notify-test -method admin-dlq\n") fmt.Fprintf(os.Stderr, " notify-test -method email-send -to t@e.com -mock\n") flag.PrintDefaults() } flag.Parse() code, err := run() if err != nil { fmt.Fprintln(os.Stderr, err) } if code != 0 { os.Exit(code) } } // run wires the requested method and returns (exitCode, error). Deferred // cleanups inside run always execute before main calls os.Exit. func run() (int, error) { m := strings.TrimSpace(*method) if m == "" { flag.Usage() return 2, fmt.Errorf("notify-test: -method is required") } if !isValidMethod(m) { flag.Usage() return 2, fmt.Errorf("notify-test: unknown method %q", m) } if err := validateArgs(m); err != nil { return 2, fmt.Errorf("notify-test: %w", err) } var c config.Config conf.MustLoad(*configFile, &c) if c.Mongo.Host == "" { return 1, fmt.Errorf("notify-test: Mongo.Host is empty") } if c.Redis.Host == "" { return 1, fmt.Errorf("notify-test: Redis.Host is empty") } if c.Notification.Email.From == "" && needsEmailFrom(m) { return 1, fmt.Errorf("notify-test: Notification.Email.From is empty") } if *mockOnly { forceMock(&c.Notification) } ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*pollSec+60)*time.Second) defer cancel() rds, err := redislib.NewClient(c.Redis) if err != nil { return 1, fmt.Errorf("notify-test: redis: %w", err) } mod, err := notifusecase.NewModuleFromParam(notifusecase.FactoryParam{ MongoConf: &c.Mongo, Redis: rds, Config: c.Notification, }) if err != nil { return 1, fmt.Errorf("notify-test: notification: %w", err) } var otpUC dommember.OTPUseCase if m == methodMemberEmail || m == methodMemberPhone { memberMod, memErr := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{ Redis: rds, Config: c.Member, }) if memErr != nil { return 1, fmt.Errorf("notify-test: member: %w", memErr) } otpUC = memberMod.OTP } e := &env{ ctx: ctx, tenant: *tenantID, uid: *uid, to: *toEmail, phone: *phone, locale: c.Notification.DefaultLocale, notifier: mod.Notifier, otp: otpUC, admin: mod.Admin, } if m == methodEmailEnqueue || m == methodSMSEnqueue { if mod.RetryWorker == nil { return 1, fmt.Errorf("notify-test: retry worker not configured (need Redis)") } workerCtx, stop := context.WithCancel(context.Background()) go mod.RetryWorker.Run(workerCtx) defer stop() } fmt.Printf("method=%s email=%s sms=%s\n", m, strings.Join(emailProviders(&c.Notification), ","), strings.Join(smsProviders(&c.Notification), ",")) if runErr := runMethod(e, m); runErr != nil { return 1, fmt.Errorf("FAIL: %w", runErr) } fmt.Println("OK") return 0, nil } func runMethod(e *env, m string) error { switch m { case methodEmailSend: return e.emailSend() case methodEmailEnqueue: return e.emailEnqueue() case methodEmailIdempotency: return e.emailIdempotency() case methodSMSSend: return e.smsSend() case methodSMSEnqueue: return e.smsEnqueue() case methodMemberEmail: return e.memberEmail() case methodMemberPhone: return e.memberPhone() case methodAdminDLQ: return e.adminDLQ() default: return fmt.Errorf("unhandled method %q", m) } } func (e *env) emailSend() error { dto, err := e.notifier.Send(e.ctx, &domusecase.SendRequest{ TenantID: e.tenant, UID: e.uid, Channel: enum.ChannelEmail, Kind: enum.NotifyVerifyEmail, Target: e.to, Locale: e.locale, Data: map[string]any{domtpl.VarCode: "123456", domtpl.VarExpiresIn: 300}, IdempotencyKey: uuid.NewString(), DoNotPersistBody: true, Severity: enum.SeverityInfo, }) return reportSent(dto, err) } func (e *env) emailEnqueue() error { pending, err := e.notifier.Enqueue(e.ctx, &domusecase.SendRequest{ TenantID: e.tenant, UID: e.uid, Channel: enum.ChannelEmail, Kind: enum.NotifyTenantWelcome, Target: e.to, Locale: e.locale, Data: map[string]any{"tenant_name": "Test Corp"}, IdempotencyKey: uuid.NewString(), DoNotPersistBody: false, Severity: enum.SeverityInfo, }) if err != nil { return err } final, err := waitSent(e.ctx, e.notifier, e.tenant, pending.ID, time.Duration(*pollSec)*time.Second) if err != nil { return err } fmt.Printf("notification_id=%s provider=%s status=%s\n", final.ID, final.Provider, final.Status) return nil } func (e *env) emailIdempotency() error { key := uuid.NewString() first, err := e.notifier.Send(e.ctx, &domusecase.SendRequest{ TenantID: e.tenant, UID: e.uid, Channel: enum.ChannelEmail, Kind: enum.NotifyVerifyEmail, Target: e.to, Locale: e.locale, Data: map[string]any{domtpl.VarCode: "111111", domtpl.VarExpiresIn: 300}, IdempotencyKey: key, Severity: enum.SeverityInfo, }) if err != nil { return err } second, err := e.notifier.Send(e.ctx, &domusecase.SendRequest{ TenantID: e.tenant, UID: e.uid, Channel: enum.ChannelEmail, Kind: enum.NotifyVerifyEmail, Target: e.to, Locale: e.locale, Data: map[string]any{domtpl.VarCode: "222222", domtpl.VarExpiresIn: 300}, IdempotencyKey: key, Severity: enum.SeverityInfo, }) if err != nil { return err } if first.ID != second.ID { return fmt.Errorf("idempotency: expected same id, got %s vs %s", first.ID, second.ID) } fmt.Printf("notification_id=%s (replay ok)\n", first.ID) return nil } func (e *env) smsSend() error { dto, err := e.notifier.Send(e.ctx, &domusecase.SendRequest{ TenantID: e.tenant, UID: e.uid, Channel: enum.ChannelSMS, Kind: enum.NotifyVerifyPhone, Target: e.phone, Locale: e.locale, Data: map[string]any{domtpl.VarCode: "123456", domtpl.VarExpiresIn: 300}, IdempotencyKey: uuid.NewString(), DoNotPersistBody: true, Severity: enum.SeverityInfo, }) return reportSent(dto, err) } func (e *env) smsEnqueue() error { pending, err := e.notifier.Enqueue(e.ctx, &domusecase.SendRequest{ TenantID: e.tenant, UID: e.uid, Channel: enum.ChannelSMS, Kind: enum.NotifyVerifyPhone, Target: e.phone, Locale: e.locale, Data: map[string]any{domtpl.VarCode: "654321", domtpl.VarExpiresIn: 300}, IdempotencyKey: uuid.NewString(), Severity: enum.SeverityInfo, }) if err != nil { return err } final, err := waitSent(e.ctx, e.notifier, e.tenant, pending.ID, time.Duration(*pollSec)*time.Second) if err != nil { return err } fmt.Printf("notification_id=%s provider=%s\n", final.ID, final.Provider) return nil } // memberEmail demonstrates the logic-layer orchestration: generate an OTP // challenge (atomic) and dispatch the verification email through Notifier // (atomic). usecases never call each other — this driver is what the real // logic handler will look like. func (e *env) memberEmail() error { return e.startMemberVerify(memberenum.OTPPurposeBusinessEmail, enum.ChannelEmail, enum.NotifyVerifyEmail, e.to) } func (e *env) memberPhone() error { return e.startMemberVerify(memberenum.OTPPurposeBusinessPhone, enum.ChannelSMS, enum.NotifyVerifyPhone, e.phone) } func (e *env) startMemberVerify(purpose memberenum.OTPPurpose, channel enum.Channel, kind enum.NotifyKind, target string) error { if e.otp == nil { return fmt.Errorf("member OTP usecase not configured") } if target == "" { return fmt.Errorf("target is empty") } dto, code, err := e.otp.Generate(e.ctx, &dommember.GenerateOTPRequest{ TenantID: e.tenant, UID: e.uid, Purpose: purpose, Target: target, }) if err != nil { return err } if _, err := e.notifier.Send(e.ctx, &domusecase.SendRequest{ TenantID: e.tenant, UID: e.uid, Channel: channel, Kind: kind, Target: target, Locale: e.locale, Data: map[string]any{"code": code, "expires_in": dto.ExpiresIn}, IdempotencyKey: dto.ChallengeID, DoNotPersistBody: true, Severity: enum.SeverityInfo, }); err != nil { if invErr := e.otp.Invalidate(e.ctx, dto.ChallengeID); invErr != nil { fmt.Fprintf(os.Stderr, "warn: invalidate otp after send failure: %v\n", invErr) } return err } fmt.Printf("challenge_id=%s expires_in=%d\n", dto.ChallengeID, dto.ExpiresIn) return nil } func (e *env) adminDLQ() error { if e.admin == nil { return fmt.Errorf("admin notifier not configured") } entries, err := e.admin.ListDLQ(e.ctx, e.tenant, 10) if err != nil { return err } fmt.Printf("dlq_count=%d\n", len(entries)) return nil } func reportSent(dto *domusecase.NotificationDTO, err error) error { if err != nil { return err } if dto.Status != enum.NotifyStatusSent { return fmt.Errorf("status=%s last_error=%s", dto.Status, dto.LastError) } fmt.Printf("notification_id=%s provider=%s message_id=%s\n", dto.ID, dto.Provider, dto.ProviderMessageID) return nil } func waitSent(ctx context.Context, notifier domusecase.NotifierUseCase, tenantID, notificationID string, timeout time.Duration) (*domusecase.NotificationDTO, error) { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { dto, err := notifier.Get(ctx, tenantID, notificationID) if err != nil { return nil, err } switch dto.Status { case enum.NotifyStatusSent: return dto, nil case enum.NotifyStatusFailed, enum.NotifyStatusDropped: return dto, fmt.Errorf("status=%s: %s", dto.Status, dto.LastError) } select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(500 * time.Millisecond): } } return nil, fmt.Errorf("timeout after %s", timeout) } func validateArgs(m string) error { switch m { case methodEmailSend, methodEmailEnqueue, methodEmailIdempotency, methodMemberEmail: if *toEmail == "" { return fmt.Errorf("%s requires -to", m) } case methodSMSSend, methodSMSEnqueue, methodMemberPhone: if *phone == "" { return fmt.Errorf("%s requires -phone", m) } } return nil } func needsEmailFrom(m string) bool { switch m { case methodEmailSend, methodEmailEnqueue, methodEmailIdempotency, methodMemberEmail: return true default: return false } } func isValidMethod(m string) bool { for _, v := range validMethods { if v == m { return true } } return false } func forceMock(cfg *notifconfig.Config) { cfg.Email.SMTP.Enable = false cfg.Email.SES.Enable = false cfg.Email.Provider = notifconfig.ProviderMock cfg.SMS.Mitake.Enable = false cfg.SMS.Provider = notifconfig.ProviderMock } func emailProviders(cfg *notifconfig.Config) []string { var out []string if cfg.Email.SMTP.Enable { out = append(out, "smtp") } if cfg.Email.SES.Enable { out = append(out, "ses") } if len(out) == 0 { out = append(out, "mock") } return out } func smsProviders(cfg *notifconfig.Config) []string { if cfg.SMS.Mitake.Enable { return []string{"mitake"} } return []string{"mock"} }