template-monorepo/cmd/notify-test/main.go

488 lines
14 KiB
Go

// 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 <name> [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"}
}