package usecase_test import ( "context" "testing" errs "gateway/internal/library/errors" "gateway/internal/model/notification" "gateway/internal/model/notification/config" domentity "gateway/internal/model/notification/domain/entity" "gateway/internal/model/notification/domain/enum" domrepo "gateway/internal/model/notification/domain/repository" domtpl "gateway/internal/model/notification/domain/template" domusecase "gateway/internal/model/notification/domain/usecase" mocknotifrepo "gateway/internal/model/notification/mock/repository" "gateway/internal/model/notification/provider/email" "gateway/internal/model/notification/provider/sms" "gateway/internal/model/notification/repository" "gateway/internal/model/notification/template" "gateway/internal/model/notification/usecase" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/mock/gomock" ) const ( testTenantID = "tenant-1" testEmailFrom = "noreply@test.com" testEmailTarget = "testEmailTarget" testTenantName = "Acme" varTenantName = "tenant_name" ) func newTestNotifier(t *testing.T, repo domrepo.NotificationRepository, queue domrepo.RetryQueue) domusecase.NotifierUseCase { t.Helper() return usecase.MustNotifierUseCase(usecase.NotifierUseCaseParam{ Repo: repo, Idempotency: repository.NewMemoryIdempotencyCache(), Quota: repository.NewMemoryQuotaCounter(), RetryQueue: queue, Renderer: template.NewRenderer(template.DefaultRegistry(), domtpl.LocaleZhTW, domtpl.LocaleEnUS), Email: email.NewChain(email.NewMockSender( email.WithMockName("mock"), email.WithMockMessageID("email-msg-1"), )), SMS: sms.NewChain(sms.NewMockSender( sms.WithMockName("mock"), sms.WithMockMessageID("sms-msg-1"), )), Config: config.Config{ DefaultLocale: domtpl.LocaleZhTW, RatePerTenant: config.RatePerTenantConfig{Email: 100, SMS: 100}, Email: config.EmailConfig{From: testEmailFrom}, }, }) } func expectIdempotencyMiss(repo *mocknotifrepo.MockNotificationRepository, kind enum.NotifyKind, idemKey string) { repo.EXPECT(). FindByIdempotency(gomock.Any(), testTenantID, kind, idemKey). Return(nil, notification.ErrNotFound) } func expectInsertAssignsID(repo *mocknotifrepo.MockNotificationRepository) { repo.EXPECT().Insert(gomock.Any(), gomock.Any()).DoAndReturn( func(_ context.Context, data *domentity.Notification) error { if data.ID.IsZero() { data.ID = bson.NewObjectID() } return nil }, ) } func TestNotifier_Send_EmailSuccess(t *testing.T) { ctrl := gomock.NewController(t) repo := mocknotifrepo.NewMockNotificationRepository(ctrl) expectIdempotencyMiss(repo, enum.NotifyVerifyEmail, "challenge-1") expectInsertAssignsID(repo) repo.EXPECT().UpdateDelivery(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) uc := newTestNotifier(t, repo, nil) dto, err := uc.Send(context.Background(), &domusecase.SendRequest{ TenantID: testTenantID, UID: "uid-1", Channel: enum.ChannelEmail, Kind: enum.NotifyVerifyEmail, Target: testEmailTarget, Locale: domtpl.LocaleZhTW, Data: map[string]any{domtpl.VarCode: "123456", domtpl.VarExpiresIn: 300}, IdempotencyKey: "challenge-1", Severity: enum.SeverityInfo, }) require.NoError(t, err) assert.Equal(t, enum.NotifyStatusSent, dto.Status) assert.Equal(t, "mock", dto.Provider) assert.Equal(t, "email-msg-1", dto.ProviderMessageID) assert.NotEmpty(t, dto.TargetHash) } func TestNotifier_Send_IdempotentReplay(t *testing.T) { ctrl := gomock.NewController(t) repo := mocknotifrepo.NewMockNotificationRepository(ctrl) expectIdempotencyMiss(repo, enum.NotifyVerifyPhone, "challenge-2") expectInsertAssignsID(repo) repo.EXPECT().UpdateDelivery(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) uc := newTestNotifier(t, repo, nil) req := &domusecase.SendRequest{ TenantID: testTenantID, Channel: enum.ChannelSMS, Kind: enum.NotifyVerifyPhone, Target: "+886912345678", Data: map[string]any{domtpl.VarCode: "111111", domtpl.VarExpiresIn: 300}, IdempotencyKey: "challenge-2", } first, err := uc.Send(context.Background(), req) require.NoError(t, err) second, err := uc.Send(context.Background(), req) require.NoError(t, err) assert.Equal(t, first.ID, second.ID) } func TestNotifier_Send_DoNotPersistBody(t *testing.T) { ctrl := gomock.NewController(t) repo := mocknotifrepo.NewMockNotificationRepository(ctrl) expectIdempotencyMiss(repo, enum.NotifyStepUpEmail, "challenge-3") expectInsertAssignsID(repo) repo.EXPECT().UpdateDelivery(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( func(_ context.Context, _, _ string, update *domrepo.NotificationDeliveryUpdate) error { assert.Empty(t, update.Body) return nil }, ) uc := newTestNotifier(t, repo, nil) _, err := uc.Send(context.Background(), &domusecase.SendRequest{ TenantID: testTenantID, Channel: enum.ChannelEmail, Kind: enum.NotifyStepUpEmail, Target: "testEmailTarget", Data: map[string]any{domtpl.VarCode: "999999", domtpl.VarExpiresIn: 300}, IdempotencyKey: "challenge-3", DoNotPersistBody: true, }) require.NoError(t, err) } func TestNotifier_Enqueue_Pending(t *testing.T) { ctrl := gomock.NewController(t) repo := mocknotifrepo.NewMockNotificationRepository(ctrl) queue := mocknotifrepo.NewMockRetryQueue(ctrl) expectIdempotencyMiss(repo, enum.NotifyTenantWelcome, "welcome-1") expectInsertAssignsID(repo) queue.EXPECT().Schedule(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) uc := newTestNotifier(t, repo, queue) dto, err := uc.Enqueue(context.Background(), &domusecase.SendRequest{ TenantID: testTenantID, Channel: enum.ChannelEmail, Kind: enum.NotifyTenantWelcome, Target: "admin@example.com", Data: map[string]any{varTenantName: testTenantName}, IdempotencyKey: "welcome-1", }) require.NoError(t, err) assert.Equal(t, enum.NotifyStatusPending, dto.Status) } func TestNotifier_Send_QuotaExceeded(t *testing.T) { ctrl := gomock.NewController(t) repo := mocknotifrepo.NewMockNotificationRepository(ctrl) gomock.InOrder( repo.EXPECT().FindByIdempotency(gomock.Any(), testTenantID, enum.NotifyVerifyEmail, "q1"). Return(nil, notification.ErrNotFound), repo.EXPECT().Insert(gomock.Any(), gomock.Any()).DoAndReturn( func(_ context.Context, data *domentity.Notification) error { if data.ID.IsZero() { data.ID = bson.NewObjectID() } return nil }, ), repo.EXPECT().UpdateDelivery(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil), repo.EXPECT().FindByIdempotency(gomock.Any(), testTenantID, enum.NotifyVerifyEmail, "q2"). Return(nil, notification.ErrNotFound), ) uc := usecase.MustNotifierUseCase(usecase.NotifierUseCaseParam{ Repo: repo, Quota: repository.NewMemoryQuotaCounter(), Renderer: template.NewRenderer(template.DefaultRegistry(), domtpl.LocaleZhTW), Email: email.NewChain(email.NewMockSender()), Config: config.Config{ RatePerTenant: config.RatePerTenantConfig{Email: 1}, Email: config.EmailConfig{From: testEmailFrom}, }, }) _, err := uc.Send(context.Background(), &domusecase.SendRequest{ TenantID: testTenantID, Channel: enum.ChannelEmail, Kind: enum.NotifyVerifyEmail, Target: "a@test.com", Data: map[string]any{domtpl.VarCode: "1", domtpl.VarExpiresIn: 60}, IdempotencyKey: "q1", }) require.NoError(t, err) _, err = uc.Send(context.Background(), &domusecase.SendRequest{ TenantID: testTenantID, Channel: enum.ChannelEmail, Kind: enum.NotifyVerifyEmail, Target: "b@test.com", Data: map[string]any{domtpl.VarCode: "2", domtpl.VarExpiresIn: 60}, IdempotencyKey: "q2", }) require.Error(t, err) var appErr *errs.Error require.ErrorAs(t, err, &appErr) }