// 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" 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 verification dommember.VerificationUseCase 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() m := strings.TrimSpace(*method) if m == "" { fmt.Fprintln(os.Stderr, "notify-test: -method is required") flag.Usage() os.Exit(2) } if !isValidMethod(m) { fmt.Fprintf(os.Stderr, "notify-test: unknown method %q\n", m) flag.Usage() os.Exit(2) } if err := validateArgs(m); err != nil { fmt.Fprintf(os.Stderr, "notify-test: %v\n", err) os.Exit(2) } var c config.Config conf.MustLoad(*configFile, &c) if c.Mongo.Host == "" { fail("Mongo.Host is empty") } if c.Redis.Host == "" { fail("Redis.Host is empty") } if c.Notification.Email.From == "" && needsEmailFrom(m) { fail("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 { fail("redis: %v", err) } mod, err := notifusecase.NewModuleFromParam(notifusecase.FactoryParam{ MongoConf: &c.Mongo, Redis: rds, Config: c.Notification, }) if err != nil { fail("notification: %v", err) } var verification dommember.VerificationUseCase if m == methodMemberEmail || m == methodMemberPhone { memberMod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{ Redis: rds, Notifier: mod.Notifier, Config: c.Member, }) if err != nil { fail("member: %v", err) } verification = memberMod.Verification } e := &env{ ctx: ctx, tenant: *tenantID, uid: *uid, to: *toEmail, phone: *phone, locale: c.Notification.DefaultLocale, notifier: mod.Notifier, verification: verification, admin: mod.Admin, } if m == methodEmailEnqueue || m == methodSMSEnqueue { if mod.RetryWorker == nil { fail("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 err := runMethod(e, m); err != nil { fmt.Fprintf(os.Stderr, "FAIL: %v\n", err) os.Exit(1) } fmt.Println("OK") } 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 } func (e *env) memberEmail() error { ch, err := e.verification.StartEmailVerify(e.ctx, e.tenant, e.uid, e.to, "zh-tw") if err != nil { return err } fmt.Printf("challenge_id=%s expires_in=%d\n", ch.ChallengeID, ch.ExpiresIn) return nil } func (e *env) memberPhone() error { ch, err := e.verification.StartPhoneVerify(e.ctx, e.tenant, e.uid, e.phone, "zh-tw") if err != nil { return err } fmt.Printf("challenge_id=%s expires_in=%d\n", ch.ChallengeID, ch.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"} } func fail(format string, args ...any) { fmt.Fprintf(os.Stderr, "notify-test: "+format+"\n", args...) os.Exit(1) }