/* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package notification import ( "context" "encoding/json" "os" "reflect" "sync" "testing" "time" "github.com/apache/answer/internal/base/constant" basedata "github.com/apache/answer/internal/base/data" "github.com/apache/answer/internal/entity" "github.com/apache/answer/internal/schema" "github.com/apache/answer/internal/service/config" "github.com/apache/answer/internal/service/export" "github.com/apache/answer/internal/service/mock" "github.com/apache/answer/plugin" "go.uber.org/mock/gomock" ) func TestNewQuestionNotificationEmailSendInterval(t *testing.T) { tests := []struct { name string value string set bool want time.Duration }{ { name: "unset", want: 0, }, { name: "empty", value: "", set: true, want: 0, }, { name: "positive integer", value: "5", set: true, want: 5 * time.Second, }, { name: "positive integer with whitespace", value: " 5 ", set: true, want: 5 * time.Second, }, { name: "invalid", value: "not-a-number", set: true, want: 0, }, { name: "negative", value: "-1", set: true, want: 0, }, { name: "whitespace", value: " ", set: true, want: 0, }, { name: "above max clamps to max", value: "301", set: true, want: maxNewQuestionNotificationEmailSendInterval, }, { name: "duration overflow clamps to max", value: "9223372037", set: true, want: maxNewQuestionNotificationEmailSendInterval, }, { name: "parse int overflow", value: "9223372036854775808", set: true, want: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { setNewQuestionNotificationEmailSendIntervalEnv(t, tt.value, tt.set) got := newQuestionNotificationEmailSendInterval() if got != tt.want { t.Fatalf("newQuestionNotificationEmailSendInterval() = %v, want %v", got, tt.want) } }) } } func TestHandleNewQuestionNotificationEnqueuesEmailTask(t *testing.T) { setNewQuestionNotificationEmailSendIntervalEnv(t, "0", true) cache, cleanup, err := basedata.NewCache(&basedata.CacheConf{}) if err != nil { t.Fatalf("new cache: %v", err) } t.Cleanup(cleanup) ctrl := gomock.NewController(t) siteInfoService := mock.NewMockSiteInfoCommonService(ctrl) siteInfoService.EXPECT().GetSiteGeneral(gomock.Any()).Return(&schema.SiteGeneralResp{ Name: "Answer", SiteUrl: "https://answer.test", ContactEmail: "support@answer.test", }, nil).AnyTimes() siteInfoService.EXPECT().GetSiteSeo(gomock.Any()).Return(&schema.SiteSeoResp{ Permalink: constant.PermalinkQuestionIDAndTitle, }, nil).AnyTimes() emailRepo := &newQuestionNotificationTestEmailRepo{ codesByUserID: make(map[string][]string), } notificationConfigRepo := &newQuestionNotificationTestUserNotificationConfigRepo{ followedTagConfigs: map[string]*entity.UserNotificationConfig{ "tag-user": newQuestionNotificationConfig( "tag-user", constant.AllNewQuestionForFollowingTagsSource, true), "dup-user": newQuestionNotificationConfig( "dup-user", constant.AllNewQuestionForFollowingTagsSource, true), "author": newQuestionNotificationConfig( "author", constant.AllNewQuestionForFollowingTagsSource, true), }, allQuestionConfigs: []*entity.UserNotificationConfig{ newQuestionNotificationConfig("all-user", constant.AllNewQuestionSource, true), newQuestionNotificationConfig("dup-user", constant.AllNewQuestionSource, true), newQuestionNotificationConfig("author", constant.AllNewQuestionSource, true), }, } service := &ExternalNotificationService{ data: &basedata.Data{ Cache: cache, }, userNotificationConfigRepo: notificationConfigRepo, followRepo: &newQuestionNotificationTestFollowRepo{ followersByObjectID: map[string][]string{ "tag-1": {"tag-user", "dup-user", "author"}, }, }, emailService: export.NewEmailService( config.NewConfigService(newQuestionNotificationTestConfigRepo{}), emailRepo, siteInfoService, ), userRepo: &newQuestionNotificationTestUserRepo{ users: map[string]*entity.User{ "tag-user": newQuestionNotificationTestUser("tag-user"), "dup-user": newQuestionNotificationTestUser("dup-user"), "all-user": newQuestionNotificationTestUser("all-user"), "author": newQuestionNotificationTestUser("author"), }, }, siteInfoService: siteInfoService, } service.newQuestionEmailWorker = newUnstartedNewQuestionEmailWorkerForTest() err = service.handleNewQuestionNotification(context.Background(), &schema.ExternalNotificationMsg{ NewQuestionTemplateRawData: &schema.NewQuestionTemplateRawData{ QuestionTitle: "New question", QuestionID: "1", QuestionAuthorUserID: "author", Tags: []string{"go"}, TagIDs: []string{"tag-1"}, }, }) if err != nil { t.Fatalf("handleNewQuestionNotification() error = %v", err) } var task newQuestionEmailTask select { case task = <-service.newQuestionEmailWorker.tasks: default: t.Fatalf("expected enqueued new question email task") } wantUsers := []string{"all-user", "dup-user", "tag-user"} assertStringSet(t, task.UserIDs, wantUsers) if task.QuestionTitle != "New question" || task.QuestionID != "1" { t.Fatalf("task question data = %+v", task) } if !reflect.DeepEqual(task.Tags, []string{"go"}) || !reflect.DeepEqual(task.TagIDs, []string{"tag-1"}) { t.Fatalf("task tags = %v/%v", task.Tags, task.TagIDs) } if len(emailRepo.codesByUserID) > 0 { t.Fatalf("handler sent emails synchronously: %v", emailRepo.codesByUserID) } } func TestHandleNewQuestionNotificationSkipsEnqueueWithoutEnabledEmailAttempts(t *testing.T) { cache, cleanup, err := basedata.NewCache(&basedata.CacheConf{}) if err != nil { t.Fatalf("new cache: %v", err) } t.Cleanup(cleanup) service := &ExternalNotificationService{ data: &basedata.Data{Cache: cache}, userNotificationConfigRepo: &newQuestionNotificationTestUserNotificationConfigRepo{ followedTagConfigs: map[string]*entity.UserNotificationConfig{ "tag-user": newQuestionNotificationConfig( "tag-user", constant.AllNewQuestionForFollowingTagsSource, false), }, allQuestionConfigs: []*entity.UserNotificationConfig{ newQuestionNotificationConfig("all-user", constant.AllNewQuestionSource, false), }, }, followRepo: &newQuestionNotificationTestFollowRepo{ followersByObjectID: map[string][]string{"tag-1": {"tag-user"}}, }, userRepo: &newQuestionNotificationTestUserRepo{ users: map[string]*entity.User{ "tag-user": newQuestionNotificationTestUser("tag-user"), "all-user": newQuestionNotificationTestUser("all-user"), }, }, newQuestionEmailWorker: newUnstartedNewQuestionEmailWorkerForTest(), } err = service.handleNewQuestionNotification(context.Background(), &schema.ExternalNotificationMsg{ NewQuestionTemplateRawData: &schema.NewQuestionTemplateRawData{ QuestionTitle: "New question", QuestionID: "1", Tags: []string{"go"}, TagIDs: []string{"tag-1"}, }, }) if err != nil { t.Fatalf("handleNewQuestionNotification() error = %v", err) } select { case task := <-service.newQuestionEmailWorker.tasks: t.Fatalf("unexpected enqueued task: %+v", task) default: } } func TestHandleNewQuestionNotificationReturnsWhenEmailWorkerQueueFull(t *testing.T) { cache, cleanup, err := basedata.NewCache(&basedata.CacheConf{}) if err != nil { t.Fatalf("new cache: %v", err) } t.Cleanup(cleanup) worker := newUnstartedNewQuestionEmailWorkerForTest() if !worker.TryEnqueue(newQuestionEmailWorkerTask("already-queued", "queued-user")) { t.Fatalf("pre-fill TryEnqueue() = false, want true") } service := &ExternalNotificationService{ data: &basedata.Data{Cache: cache}, userNotificationConfigRepo: &newQuestionNotificationTestUserNotificationConfigRepo{ allQuestionConfigs: []*entity.UserNotificationConfig{ newQuestionNotificationConfig("all-user", constant.AllNewQuestionSource, true), }, }, followRepo: &newQuestionNotificationTestFollowRepo{ followersByObjectID: map[string][]string{}, }, userRepo: &newQuestionNotificationTestUserRepo{ users: map[string]*entity.User{ "all-user": newQuestionNotificationTestUser("all-user"), }, }, newQuestionEmailWorker: worker, } err = service.handleNewQuestionNotification(context.Background(), &schema.ExternalNotificationMsg{ NewQuestionTemplateRawData: &schema.NewQuestionTemplateRawData{ QuestionTitle: "New question", QuestionID: "1", }, }) if err != nil { t.Fatalf("handleNewQuestionNotification() error = %v", err) } if got := len(worker.tasks); got != 1 { t.Fatalf("worker queue length = %d, want 1", got) } } func TestHandleNewQuestionNotificationSyncsPluginBeforeEmailEnqueue(t *testing.T) { cache, cleanup, err := basedata.NewCache(&basedata.CacheConf{}) if err != nil { t.Fatalf("new cache: %v", err) } t.Cleanup(cleanup) ctrl := gomock.NewController(t) siteInfoService := mock.NewMockSiteInfoCommonService(ctrl) siteInfoService.EXPECT().GetSiteGeneral(gomock.Any()).Return(&schema.SiteGeneralResp{ Name: "Answer", SiteUrl: "https://answer.test", ContactEmail: "support@answer.test", }, nil).AnyTimes() siteInfoService.EXPECT().GetSiteSeo(gomock.Any()).Return(&schema.SiteSeoResp{ Permalink: constant.PermalinkQuestionIDAndTitle, }, nil).AnyTimes() siteInfoService.EXPECT().GetSiteInterface(gomock.Any()).Return(&schema.SiteInterfaceSettingsResp{ Language: "en", }, nil).AnyTimes() notifyStarted := make(chan plugin.NotificationMessage, 1) releaseNotify := make(chan struct{}) enableNewQuestionNotificationTestPlugin(t, notifyStarted, releaseNotify) worker := newUnstartedNewQuestionEmailWorkerForTest() service := &ExternalNotificationService{ data: &basedata.Data{Cache: cache}, userNotificationConfigRepo: &newQuestionNotificationTestUserNotificationConfigRepo{ followedTagConfigs: map[string]*entity.UserNotificationConfig{ "tag-user": newQuestionNotificationConfig( "tag-user", constant.AllNewQuestionForFollowingTagsSource, true), }, }, followRepo: &newQuestionNotificationTestFollowRepo{ followersByObjectID: map[string][]string{"tag-1": {"tag-user"}}, }, userRepo: &newQuestionNotificationTestUserRepo{ users: map[string]*entity.User{ "tag-user": newQuestionNotificationTestUser("tag-user"), }, }, userExternalLoginRepo: newQuestionNotificationTestUserExternalLoginRepo{}, siteInfoService: siteInfoService, newQuestionEmailWorker: worker, } errCh := make(chan error, 1) go func() { errCh <- service.handleNewQuestionNotification(context.Background(), &schema.ExternalNotificationMsg{ NewQuestionTemplateRawData: &schema.NewQuestionTemplateRawData{ QuestionTitle: "New question", QuestionID: "1", Tags: []string{"go"}, TagIDs: []string{"tag-1"}, }, }) }() select { case <-notifyStarted: case <-time.After(time.Second): t.Fatalf("plugin notification was not sent") } select { case task := <-worker.tasks: t.Fatalf("email task enqueued before plugin sync completed: %+v", task) default: } close(releaseNotify) select { case err := <-errCh: if err != nil { t.Fatalf("handleNewQuestionNotification() error = %v", err) } case <-time.After(time.Second): t.Fatalf("handleNewQuestionNotification() did not return") } select { case task := <-worker.tasks: assertStringSet(t, task.UserIDs, []string{"tag-user"}) default: t.Fatalf("expected email task after plugin sync completed") } } func assertUniqueNewQuestionUnsubscribeCodes(t *testing.T, codes []string) { t.Helper() seen := make(map[string]bool) for _, code := range codes { if seen[code] { t.Fatalf("duplicate unsubscribe code %q", code) } seen[code] = true } } func setNewQuestionNotificationEmailSendIntervalEnv(t *testing.T, value string, set bool) { t.Helper() oldValue, oldSet := os.LookupEnv(newQuestionNotificationEmailSendIntervalEnv) if set { if err := os.Setenv(newQuestionNotificationEmailSendIntervalEnv, value); err != nil { t.Fatalf("set env: %v", err) } } else { if err := os.Unsetenv(newQuestionNotificationEmailSendIntervalEnv); err != nil { t.Fatalf("unset env: %v", err) } } t.Cleanup(func() { if oldSet { _ = os.Setenv(newQuestionNotificationEmailSendIntervalEnv, oldValue) } else { _ = os.Unsetenv(newQuestionNotificationEmailSendIntervalEnv) } }) } func newQuestionEmailChannel(enable bool) *schema.NotificationChannelConfig { return &schema.NotificationChannelConfig{ Key: constant.EmailChannel, Enable: enable, } } func newQuestionNotificationConfig( userID string, source constant.NotificationSource, emailEnabled bool) *entity.UserNotificationConfig { channels := schema.NotificationChannels{ newQuestionEmailChannel(emailEnabled), } return &entity.UserNotificationConfig{ UserID: userID, Source: string(source), Channels: channels.ToJsonString(), Enabled: emailEnabled, } } func newQuestionNotificationTestUser(userID string) *entity.User { return &entity.User{ ID: userID, Username: userID, DisplayName: userID, EMail: userID + "@example.com", Status: entity.UserStatusAvailable, MailStatus: entity.EmailStatusAvailable, } } func assertStringSet(t *testing.T, got, want []string) { t.Helper() gotSet := make(map[string]bool) for _, value := range got { gotSet[value] = true } wantSet := make(map[string]bool) for _, value := range want { wantSet[value] = true } if !reflect.DeepEqual(gotSet, wantSet) { t.Fatalf("values = %v, want %v", got, want) } } type newQuestionNotificationTestFollowRepo struct { followersByObjectID map[string][]string } func (r *newQuestionNotificationTestFollowRepo) GetFollowIDs( context.Context, string, string) ([]string, error) { return nil, nil } func (r *newQuestionNotificationTestFollowRepo) GetFollowAmount(context.Context, string) (int, error) { return 0, nil } func (r *newQuestionNotificationTestFollowRepo) GetFollowUserIDs( _ context.Context, objectID string) ([]string, error) { return r.followersByObjectID[objectID], nil } func (r *newQuestionNotificationTestFollowRepo) IsFollowed(context.Context, string, string) (bool, error) { return false, nil } func (r *newQuestionNotificationTestFollowRepo) MigrateFollowers( context.Context, string, string, string) error { return nil } type newQuestionNotificationTestUserNotificationConfigRepo struct { followedTagConfigs map[string]*entity.UserNotificationConfig allQuestionConfigs []*entity.UserNotificationConfig } func (r *newQuestionNotificationTestUserNotificationConfigRepo) Add( context.Context, []string, string, string) error { return nil } func (r *newQuestionNotificationTestUserNotificationConfigRepo) Save( context.Context, *entity.UserNotificationConfig) error { return nil } func (r *newQuestionNotificationTestUserNotificationConfigRepo) GetByUserID( context.Context, string) ([]*entity.UserNotificationConfig, error) { return nil, nil } func (r *newQuestionNotificationTestUserNotificationConfigRepo) GetBySource( _ context.Context, source constant.NotificationSource) ([]*entity.UserNotificationConfig, error) { if source == constant.AllNewQuestionSource { return r.allQuestionConfigs, nil } return nil, nil } func (r *newQuestionNotificationTestUserNotificationConfigRepo) GetByUserIDAndSource( context.Context, string, constant.NotificationSource) (*entity.UserNotificationConfig, bool, error) { return nil, false, nil } func (r *newQuestionNotificationTestUserNotificationConfigRepo) GetByUsersAndSource( _ context.Context, userIDs []string, source constant.NotificationSource) ( []*entity.UserNotificationConfig, error) { if source != constant.AllNewQuestionForFollowingTagsSource { return nil, nil } configs := make([]*entity.UserNotificationConfig, 0, len(userIDs)) for _, userID := range userIDs { if config, ok := r.followedTagConfigs[userID]; ok { configs = append(configs, config) } } return configs, nil } type newQuestionNotificationTestUserRepo struct { users map[string]*entity.User } func (r *newQuestionNotificationTestUserRepo) AddUser(context.Context, *entity.User) error { return nil } func (r *newQuestionNotificationTestUserRepo) IncreaseAnswerCount(context.Context, string, int) error { return nil } func (r *newQuestionNotificationTestUserRepo) IncreaseQuestionCount(context.Context, string, int) error { return nil } func (r *newQuestionNotificationTestUserRepo) UpdateQuestionCount(context.Context, string, int64) error { return nil } func (r *newQuestionNotificationTestUserRepo) UpdateAnswerCount(context.Context, string, int) error { return nil } func (r *newQuestionNotificationTestUserRepo) UpdateLastLoginDate(context.Context, string) error { return nil } func (r *newQuestionNotificationTestUserRepo) UpdateEmailStatus(context.Context, string, int) error { return nil } func (r *newQuestionNotificationTestUserRepo) UpdateNoticeStatus(context.Context, string, int) error { return nil } func (r *newQuestionNotificationTestUserRepo) UpdateEmail(context.Context, string, string) error { return nil } func (r *newQuestionNotificationTestUserRepo) UpdateUserInterface( context.Context, string, string, string) error { return nil } func (r *newQuestionNotificationTestUserRepo) UpdatePass(context.Context, string, string) error { return nil } func (r *newQuestionNotificationTestUserRepo) UpdateInfo(context.Context, *entity.User) error { return nil } func (r *newQuestionNotificationTestUserRepo) UpdateUserProfile(context.Context, *entity.User) error { return nil } func (r *newQuestionNotificationTestUserRepo) GetByUserID( _ context.Context, userID string) (*entity.User, bool, error) { user, ok := r.users[userID] return user, ok, nil } func (r *newQuestionNotificationTestUserRepo) BatchGetByID( context.Context, []string) ([]*entity.User, error) { return nil, nil } func (r *newQuestionNotificationTestUserRepo) GetByUsername( context.Context, string) (*entity.User, bool, error) { return nil, false, nil } func (r *newQuestionNotificationTestUserRepo) GetByUsernames( context.Context, []string) ([]*entity.User, error) { return nil, nil } func (r *newQuestionNotificationTestUserRepo) GetByEmail( context.Context, string) (*entity.User, bool, error) { return nil, false, nil } func (r *newQuestionNotificationTestUserRepo) GetUserCount(context.Context) (int64, error) { return 0, nil } func (r *newQuestionNotificationTestUserRepo) SearchUserListByName( context.Context, string, int, bool) ([]*entity.User, error) { return nil, nil } func (r *newQuestionNotificationTestUserRepo) IsAvatarFileUsed(context.Context, string) (bool, error) { return false, nil } type newQuestionNotificationTestConfigRepo struct{} func (newQuestionNotificationTestConfigRepo) GetConfigByID( context.Context, int) (*entity.Config, error) { return nil, nil } func (newQuestionNotificationTestConfigRepo) GetConfigByKey( context.Context, string) (*entity.Config, error) { config := export.EmailConfig{ FromEmail: "noreply@answer.test", FromName: "Answer", } value, _ := json.Marshal(config) return &entity.Config{ Value: string(value), }, nil } func (newQuestionNotificationTestConfigRepo) GetConfigByKeyFromDB( context.Context, string) (*entity.Config, error) { return nil, nil } func (newQuestionNotificationTestConfigRepo) UpdateConfig(context.Context, string, string) error { return nil } type newQuestionNotificationTestEmailRepo struct { codesByUserID map[string][]string } func (r *newQuestionNotificationTestEmailRepo) SetCode( _ context.Context, userID, code, _ string, _ time.Duration) error { r.codesByUserID[userID] = append(r.codesByUserID[userID], code) return nil } func (r *newQuestionNotificationTestEmailRepo) VerifyCode(context.Context, string) (string, error) { return "", nil } var ( newQuestionNotificationTestPluginOnce sync.Once newQuestionNotificationTestPluginInst = &newQuestionNotificationTestPlugin{} ) func enableNewQuestionNotificationTestPlugin( t *testing.T, notifyStarted chan plugin.NotificationMessage, releaseNotify <-chan struct{}, ) { t.Helper() newQuestionNotificationTestPluginInst.setChannels(notifyStarted, releaseNotify) newQuestionNotificationTestPluginOnce.Do(func() { plugin.Register(newQuestionNotificationTestPluginInst) }) plugin.StatusManager.Enable(newQuestionNotificationTestPluginInst.Info().SlugName, true) t.Cleanup(func() { plugin.StatusManager.Enable(newQuestionNotificationTestPluginInst.Info().SlugName, false) newQuestionNotificationTestPluginInst.setChannels(nil, nil) }) } type newQuestionNotificationTestPlugin struct { mu sync.Mutex notifyStarted chan plugin.NotificationMessage releaseNotify <-chan struct{} } func (p *newQuestionNotificationTestPlugin) Info() plugin.Info { return plugin.Info{SlugName: "new-question-notification-test-plugin"} } func (p *newQuestionNotificationTestPlugin) GetNewQuestionSubscribers() []string { return nil } func (p *newQuestionNotificationTestPlugin) Notify(msg plugin.NotificationMessage) { p.mu.Lock() notifyStarted := p.notifyStarted releaseNotify := p.releaseNotify p.mu.Unlock() if notifyStarted != nil { select { case notifyStarted <- msg: default: } } if releaseNotify != nil { <-releaseNotify } } func (p *newQuestionNotificationTestPlugin) setChannels( notifyStarted chan plugin.NotificationMessage, releaseNotify <-chan struct{}, ) { p.mu.Lock() defer p.mu.Unlock() p.notifyStarted = notifyStarted p.releaseNotify = releaseNotify } type newQuestionNotificationTestUserExternalLoginRepo struct{} func (newQuestionNotificationTestUserExternalLoginRepo) AddUserExternalLogin( context.Context, *entity.UserExternalLogin) error { return nil } func (newQuestionNotificationTestUserExternalLoginRepo) UpdateInfo( context.Context, *entity.UserExternalLogin) error { return nil } func (newQuestionNotificationTestUserExternalLoginRepo) GetByExternalID( context.Context, string, string) (*entity.UserExternalLogin, bool, error) { return nil, false, nil } func (newQuestionNotificationTestUserExternalLoginRepo) GetByUserID( context.Context, string, string) (*entity.UserExternalLogin, bool, error) { return nil, false, nil } func (newQuestionNotificationTestUserExternalLoginRepo) GetUserExternalLoginList( context.Context, string) ([]*entity.UserExternalLogin, error) { return nil, nil } func (newQuestionNotificationTestUserExternalLoginRepo) DeleteUserExternalLogin( context.Context, string, string) error { return nil } func (newQuestionNotificationTestUserExternalLoginRepo) DeleteUserExternalLoginByUserID( context.Context, string) error { return nil } func (newQuestionNotificationTestUserExternalLoginRepo) SetCacheUserExternalLoginInfo( context.Context, string, *schema.ExternalLoginUserInfoCache) error { return nil } func (newQuestionNotificationTestUserExternalLoginRepo) GetCacheUserExternalLoginInfo( context.Context, string) (*schema.ExternalLoginUserInfoCache, error) { return nil, nil } func (newQuestionNotificationTestUserExternalLoginRepo) SetCacheOAuthState( context.Context, string, *schema.ExternalLoginOAuthState, time.Duration) error { return nil } func (newQuestionNotificationTestUserExternalLoginRepo) GetCacheOAuthState( context.Context, string) (*schema.ExternalLoginOAuthState, error) { return nil, nil } func (newQuestionNotificationTestUserExternalLoginRepo) DeleteCacheOAuthState( context.Context, string) error { return nil }