package service import ( "context" "strings" "sync" "testing" "github.com/stretchr/testify/require" ) func TestNotificationEmailPreviewEscapesHTMLAndSanitizesSubject(t *testing.T) { ctx := context.Background() svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil) preview, err := svc.PreviewTemplate(ctx, NotificationEmailPreviewInput{ Event: NotificationEmailEventBalanceLow, Locale: "en-US,en;q=0.9", Subject: "Low balance for {{recipient_name}}\r\nInjected", HTML: `
{{recipient_name}}
Recharge`, Variables: map[string]string{ "recipient_name": ``, "recharge_url": `javascript:alert(1)`, }, }) require.NoError(t, err) require.NotContains(t, preview.Subject, "\r") require.NotContains(t, preview.Subject, "\n") require.Contains(t, preview.Subject, `Low balance for Injected`) require.Contains(t, preview.HTML, `<script>alert("x")</script>`) require.NotContains(t, preview.HTML, `javascript:alert(1)`) require.Contains(t, preview.HTML, `href=""`) } func TestNotificationEmailTemplateOverrideAndRestore(t *testing.T) { ctx := context.Background() repo := newNotificationEmailMemorySettingRepo() svc := NewNotificationEmailService(repo, nil) official, err := svc.GetTemplate(ctx, NotificationEmailEventBalanceRechargeSuccess, "en") require.NoError(t, err) require.False(t, official.IsCustom) updated, err := svc.UpdateTemplate( ctx, NotificationEmailEventBalanceRechargeSuccess, "zh-Hans", "充值完成:{{recharge_amount}}", "{{recipient_name}} 已充值 {{recharge_amount}}
", ) require.NoError(t, err) require.True(t, updated.IsCustom) require.Equal(t, "zh", updated.Locale) require.Equal(t, "充值完成:{{recharge_amount}}", updated.Subject) require.NotNil(t, updated.UpdatedAt) restored, err := svc.RestoreOfficialTemplate(ctx, NotificationEmailEventBalanceRechargeSuccess, "zh") require.NoError(t, err) require.False(t, restored.IsCustom) require.NotEqual(t, updated.Subject, restored.Subject) _, err = repo.GetValue(ctx, notificationEmailTemplateKey(NotificationEmailEventBalanceRechargeSuccess, "zh")) require.ErrorIs(t, err, ErrSettingNotFound) } func TestNotificationEmailTemplateRejectsUnsupportedPlaceholder(t *testing.T) { ctx := context.Background() svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil) _, err := svc.UpdateTemplate( ctx, NotificationEmailEventSubscriptionPurchaseSuccess, "en", "Purchased {{not_allowed}}", "{{subscription_group}}
", ) require.Error(t, err) require.Contains(t, err.Error(), "unsupported placeholder") } func TestNotificationEmailUnsubscribeOnlyAllowsOptionalEvents(t *testing.T) { ctx := context.Background() svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil) token, err := svc.createUnsubscribeToken(ctx, "User@Example.com", NotificationEmailEventBalanceLow) require.NoError(t, err) result, err := svc.Unsubscribe(ctx, token) require.NoError(t, err) require.True(t, result.Done) require.Equal(t, NotificationEmailEventBalanceLow, result.Event) unsubscribed, err := svc.IsUnsubscribed(ctx, "user@example.com", NotificationEmailEventBalanceLow) require.NoError(t, err) require.True(t, unsubscribed) transactionalToken, err := svc.createUnsubscribeToken(ctx, "user@example.com", NotificationEmailEventBalanceRechargeSuccess) require.NoError(t, err) _, err = svc.Unsubscribe(ctx, transactionalToken) require.Error(t, err) require.Contains(t, err.Error(), "transactional") } func TestNotificationEmailLocaleMemoryNormalizesAcceptLanguage(t *testing.T) { ctx := context.Background() svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil) svc.RememberRecipientLocale(ctx, 42, "User@Example.com", "zh-CN,zh;q=0.9,en;q=0.8") require.Equal(t, "zh", svc.ResolveRecipientLocale(ctx, 42, "user@example.com")) require.Equal(t, "zh", svc.ResolveRecipientLocale(ctx, 0, "user@example.com")) } type notificationEmailMemorySettingRepo struct { mu sync.RWMutex values map[string]string } func newNotificationEmailMemorySettingRepo() *notificationEmailMemorySettingRepo { return ¬ificationEmailMemorySettingRepo{values: make(map[string]string)} } func (r *notificationEmailMemorySettingRepo) Get(_ context.Context, key string) (*Setting, error) { r.mu.RLock() defer r.mu.RUnlock() value, ok := r.values[key] if !ok { return nil, ErrSettingNotFound } return &Setting{Key: key, Value: value}, nil } func (r *notificationEmailMemorySettingRepo) GetValue(ctx context.Context, key string) (string, error) { setting, err := r.Get(ctx, key) if err != nil { return "", err } return setting.Value, nil } func (r *notificationEmailMemorySettingRepo) Set(_ context.Context, key, value string) error { r.mu.Lock() defer r.mu.Unlock() r.values[key] = value return nil } func (r *notificationEmailMemorySettingRepo) GetMultiple(_ context.Context, keys []string) (map[string]string, error) { r.mu.RLock() defer r.mu.RUnlock() out := make(map[string]string, len(keys)) for _, key := range keys { if value, ok := r.values[key]; ok { out[key] = value } } return out, nil } func (r *notificationEmailMemorySettingRepo) SetMultiple(_ context.Context, settings map[string]string) error { r.mu.Lock() defer r.mu.Unlock() for key, value := range settings { r.values[key] = value } return nil } func (r *notificationEmailMemorySettingRepo) GetAll(_ context.Context) (map[string]string, error) { r.mu.RLock() defer r.mu.RUnlock() out := make(map[string]string, len(r.values)) for key, value := range r.values { out[key] = value } return out, nil } func (r *notificationEmailMemorySettingRepo) Delete(_ context.Context, key string) error { r.mu.Lock() defer r.mu.Unlock() if _, ok := r.values[key]; !ok { return ErrSettingNotFound } delete(r.values, key) return nil } func TestNotificationEmailMemorySettingRepoSatisfiesInterface(t *testing.T) { var _ SettingRepository = (*notificationEmailMemorySettingRepo)(nil) require.False(t, strings.Contains(notificationEmailPreferenceKey(NotificationEmailEventBalanceLow, "User@Example.com"), "User@Example.com")) }