package message_test import ( "context" "testing" "time" "hyapp/pkg/appcode" "hyapp/pkg/xerr" messagedomain "hyapp/services/activity-service/internal/domain/message" messageservice "hyapp/services/activity-service/internal/service/message" "hyapp/services/activity-service/internal/testutil/mysqltest" ) func TestInboxMessageLifecycleUsesMySQLFacts(t *testing.T) { ctx := appcode.WithContext(context.Background(), "lalu") repository := mysqltest.NewRepository(t) now := time.UnixMilli(1_800_000_000_000) svc := messageservice.New(messageservice.Config{NodeID: "activity-a"}, repository) svc.SetClock(func() time.Time { return now }) first, created, err := svc.CreateInboxMessage(ctx, messagedomain.CreateInput{ TargetUserID: 42, Producer: "wallet-service", ProducerEventID: "withdraw-approved-1", ProducerEventType: "withdraw_approved", MessageType: messagedomain.SectionSystem, Title: "提现审核通过", Summary: "你的提现申请已通过。", ActionType: "wallet_withdraw_detail", ActionParam: "withdrawal_id=wd_01", SentAtMS: 1_800_000_000_000, }) if err != nil || !created { t.Fatalf("CreateInboxMessage failed: created=%v message=%+v err=%v", created, first, err) } second, created, err := svc.CreateInboxMessage(ctx, messagedomain.CreateInput{ TargetUserID: 42, Producer: "activity-service", ProducerEventID: "reward-1", ProducerEventType: "activity_reward", MessageType: messagedomain.SectionActivity, Title: "活动奖励到账", Summary: "你的活动奖励已发放。", ActionType: "activity_detail", ActionParam: "activity_id=act_01", SentAtMS: 1_800_000_000_100, }) if err != nil || !created { t.Fatalf("CreateInboxMessage activity failed: created=%v message=%+v err=%v", created, second, err) } if _, created, err := svc.CreateInboxMessage(ctx, messagedomain.CreateInput{ TargetUserID: 42, Producer: "wallet-service", ProducerEventID: "withdraw-approved-1", ProducerEventType: "withdraw_approved", MessageType: messagedomain.SectionSystem, Title: "提现审核通过", Summary: "你的提现申请已通过。", ActionType: "wallet_withdraw_detail", ActionParam: "withdrawal_id=wd_01", SentAtMS: 1_800_000_000_000, }); err != nil || created { t.Fatalf("duplicate producer event should be idempotent: created=%v err=%v", created, err) } if _, _, err := svc.CreateInboxMessage(ctx, messagedomain.CreateInput{ TargetUserID: 42, Producer: "wallet-service", ProducerEventID: "withdraw-approved-1", MessageType: messagedomain.SectionSystem, Title: "不同标题", Summary: "你的提现申请已通过。", ActionType: "wallet_withdraw_detail", ActionParam: "withdrawal_id=wd_01", SentAtMS: 1_800_000_000_000, }); !xerr.IsCode(err, xerr.ProducerEventConflict) { t.Fatalf("conflicting producer event should fail, got %v", err) } tabs, err := svc.ListMessageTabs(ctx, 42) if err != nil { t.Fatalf("ListMessageTabs failed: %v", err) } if len(tabs) != 3 || tabs[0].Section != messagedomain.SectionUser || tabs[0].Source != messagedomain.SourceTencentIMSDK || tabs[1].UnreadCount != 1 || tabs[2].UnreadCount != 1 { t.Fatalf("unexpected tabs: %+v", tabs) } items, next, err := svc.ListInboxMessages(ctx, 42, messagedomain.SectionSystem, 1, "") if err != nil || next != "" || len(items) != 1 || items[0].MessageID != first.MessageID || items[0].Read() { t.Fatalf("system list mismatch: items=%+v next=%q err=%v", items, next, err) } read, err := svc.MarkInboxMessageRead(ctx, 42, first.MessageID) if err != nil || !read.Read() || read.ReadAtMS != now.UnixMilli() { t.Fatalf("MarkInboxMessageRead failed: %+v err=%v", read, err) } now = now.Add(time.Minute) readAgain, err := svc.MarkInboxMessageRead(ctx, 42, first.MessageID) if err != nil || readAgain.ReadAtMS != read.ReadAtMS { t.Fatalf("repeat read must preserve first read time: first=%+v second=%+v err=%v", read, readAgain, err) } readCount, err := svc.MarkInboxSectionRead(ctx, 42, messagedomain.SectionActivity) if err != nil || readCount != 1 { t.Fatalf("read-all activity failed: count=%d err=%v", readCount, err) } if err := svc.DeleteInboxMessage(ctx, 99, first.MessageID); !xerr.IsCode(err, xerr.MessageNotFound) { t.Fatalf("other user delete must look not found, got %v", err) } if err := svc.DeleteInboxMessage(ctx, 42, first.MessageID); err != nil { t.Fatalf("DeleteInboxMessage failed: %v", err) } items, _, err = svc.ListInboxMessages(ctx, 42, messagedomain.SectionSystem, 20, "") if err != nil || len(items) != 0 { t.Fatalf("deleted message should not be listed: items=%+v err=%v", items, err) } } func TestInboxListCursorAndExpiredMessages(t *testing.T) { ctx := appcode.WithContext(context.Background(), "lalu") repository := mysqltest.NewRepository(t) now := time.UnixMilli(1_800_000_010_000) svc := messageservice.New(messageservice.Config{NodeID: "activity-a"}, repository) svc.SetClock(func() time.Time { return now }) for _, input := range []messagedomain.CreateInput{ {TargetUserID: 42, Producer: "admin", ProducerEventID: "system-old", MessageType: messagedomain.SectionSystem, Title: "旧消息", Summary: "旧摘要", SentAtMS: 1_800_000_000_000}, {TargetUserID: 42, Producer: "admin", ProducerEventID: "system-new", MessageType: messagedomain.SectionSystem, Title: "新消息", Summary: "新摘要", SentAtMS: 1_800_000_001_000}, {TargetUserID: 42, Producer: "admin", ProducerEventID: "system-expired", MessageType: messagedomain.SectionSystem, Title: "过期消息", Summary: "过期摘要", SentAtMS: 1_800_000_000_000, ExpireAtMS: 1_800_000_005_000}, } { if _, _, err := svc.CreateInboxMessage(ctx, input); err != nil { t.Fatalf("CreateInboxMessage failed: %+v err=%v", input, err) } } firstPage, next, err := svc.ListInboxMessages(ctx, 42, messagedomain.SectionSystem, 1, "") if err != nil || len(firstPage) != 1 || firstPage[0].Title != "新消息" || next == "" { t.Fatalf("first page mismatch: items=%+v next=%q err=%v", firstPage, next, err) } secondPage, next, err := svc.ListInboxMessages(ctx, 42, messagedomain.SectionSystem, 1, next) if err != nil || len(secondPage) != 1 || secondPage[0].Title != "旧消息" || next != "" { t.Fatalf("second page mismatch: items=%+v next=%q err=%v", secondPage, next, err) } if _, _, err := svc.ListInboxMessages(ctx, 42, messagedomain.SectionActivity, 1, "bad-token"); !xerr.IsCode(err, xerr.PageTokenInvalid) { t.Fatalf("invalid cursor should fail with PAGE_TOKEN_INVALID, got %v", err) } tabs, err := svc.ListMessageTabs(ctx, 42) if err != nil || tabs[1].UnreadCount != 2 { t.Fatalf("expired message must not count unread: tabs=%+v err=%v", tabs, err) } } func TestCreateFanoutJobIsIdempotentAndDetectsCommandConflict(t *testing.T) { ctx := appcode.WithContext(context.Background(), "lalu") repository := mysqltest.NewRepository(t) svc := messageservice.New(messageservice.Config{NodeID: "activity-a"}, repository) svc.SetClock(func() time.Time { return time.UnixMilli(1_800_000_020_000) }) input := messagedomain.CreateFanoutInput{ CommandID: "cmd-fanout-1", MessageType: messagedomain.SectionActivity, TargetScope: "user_ids", TargetFilterJSON: `{"user_ids":[42,43]}`, TemplateSnapshotJSON: `{"title":"活动上线","summary":"新活动已上线"}`, BatchSize: 500, CreatedBy: "admin:1", } job, created, err := svc.CreateFanoutJob(ctx, input) if err != nil || !created || job.JobID == "" || job.Status != messagedomain.FanoutStatusPending { t.Fatalf("CreateFanoutJob failed: created=%v job=%+v err=%v", created, job, err) } retry, created, err := svc.CreateFanoutJob(ctx, input) if err != nil || created || retry.JobID != job.JobID { t.Fatalf("retry should return existing job: created=%v retry=%+v err=%v", created, retry, err) } input.TargetFilterJSON = `{"user_ids":[99]}` if _, _, err := svc.CreateFanoutJob(ctx, input); !xerr.IsCode(err, xerr.RequestConflict) { t.Fatalf("conflicting fanout command should fail, got %v", err) } } func TestNoticeHelpersCreateInboxAndFanoutPayloads(t *testing.T) { ctx := appcode.WithContext(context.Background(), "lalu") repository := mysqltest.NewRepository(t) svc := messageservice.New(messageservice.Config{NodeID: "activity-a"}, repository) svc.SetClock(func() time.Time { return time.UnixMilli(1_800_000_025_000) }) notice, err := svc.CreateActivityNotice(ctx, messageservice.NoticeCommand{ TargetUserID: 42, ProducerEventID: "activity-reward-42-1", ProducerEventType: "activity_reward", AggregateType: "activity", AggregateID: "act_01", Title: "活动奖励到账", Summary: "你的活动奖励已发放。", ActionType: "activity_detail", ActionParam: "activity_id=act_01", Metadata: map[string]any{"activity_id": "act_01"}, }) if err != nil || !notice.Created || notice.Message.MessageType != messagedomain.SectionActivity || notice.Message.Producer != "activity-service" { t.Fatalf("CreateActivityNotice failed: notice=%+v err=%v", notice, err) } job, created, err := svc.CreateNoticeFanout(ctx, messageservice.NoticeFanoutCommand{ CommandID: "admin-full-server-notice-1", MessageType: messagedomain.SectionSystem, TargetScope: messagedomain.TargetScopeUserIDs, UserIDs: []int64{42, 43, 42, 0}, ProducerEventType: "admin_full_server_notice", AggregateType: "admin_notice", AggregateID: "notice_1", Title: "全服维护通知", Summary: "今晚 23:00 进行短暂维护。", Body: "维护期间部分功能可能短暂不可用。", ActionType: "app_h5", ActionParam: "https://example.com/notice", MetadataJSON: `{"source":"admin"}`, BatchSize: 500, CreatedBy: "admin:1", }) if err != nil || !created || job.MessageType != messagedomain.SectionSystem || job.TargetScope != messagedomain.TargetScopeUserIDs { t.Fatalf("CreateNoticeFanout failed: created=%v job=%+v err=%v", created, job, err) } if job.TargetFilterJSON != `{"user_ids":[42,43]}` { t.Fatalf("fanout target filter should deduplicate user_ids, got %s", job.TargetFilterJSON) } if _, created, err := svc.CreateNoticeFanout(ctx, messageservice.NoticeFanoutCommand{ CommandID: "admin-full-server-notice-1", MessageType: messagedomain.SectionSystem, TargetScope: messagedomain.TargetScopeUserIDs, UserIDs: []int64{42, 43, 42, 0}, ProducerEventType: "admin_full_server_notice", AggregateType: "admin_notice", AggregateID: "notice_1", Title: "全服维护通知", Summary: "今晚 23:00 进行短暂维护。", Body: "维护期间部分功能可能短暂不可用。", ActionType: "app_h5", ActionParam: "https://example.com/notice", MetadataJSON: `{"source":"admin"}`, BatchSize: 500, CreatedBy: "admin:1", }); err != nil || created { t.Fatalf("notice fanout duplicate should be idempotent: created=%v err=%v", created, err) } } func TestFanoutWorkerMaterializesUserIDTargetsByCursor(t *testing.T) { ctx := appcode.WithContext(context.Background(), "lalu") repository := mysqltest.NewRepository(t) now := time.UnixMilli(1_800_000_030_000) svc := messageservice.New(messageservice.Config{NodeID: "activity-a"}, repository) svc.SetClock(func() time.Time { return now }) job, created, err := svc.CreateFanoutJob(ctx, messagedomain.CreateFanoutInput{ CommandID: "cmd-fanout-worker-1", MessageType: messagedomain.SectionActivity, TargetScope: messagedomain.TargetScopeUserIDs, TargetFilterJSON: `{"user_ids":[44,42,43,42]}`, TemplateSnapshotJSON: `{"title":"活动上线","summary":"新活动已上线","action_type":"activity_detail","action_param":"activity_id=act_01","metadata_json":{"campaign":"spring"}}`, BatchSize: 2, CreatedBy: "admin:1", }) if err != nil || !created { t.Fatalf("CreateFanoutJob failed: created=%v job=%+v err=%v", created, job, err) } processed, err := svc.ProcessNextFanoutJob(ctx, messageservice.FanoutWorkerOptions{WorkerID: "worker-a", BatchSize: 2, MaxRetry: 3, LockTTL: time.Minute}) if err != nil || !processed { t.Fatalf("first fanout batch failed: processed=%v err=%v", processed, err) } stored, exists, err := repository.GetFanoutJobByCommand(ctx, "cmd-fanout-worker-1") if err != nil || !exists || stored.Status != messagedomain.FanoutStatusPending || stored.CursorUserID != 43 || stored.SuccessCount != 2 || stored.TotalCount != 2 || stored.AttemptCount != 1 { t.Fatalf("first batch progress mismatch: exists=%v job=%+v err=%v", exists, stored, err) } for _, userID := range []int64{42, 43} { items, _, err := svc.ListInboxMessages(ctx, userID, messagedomain.SectionActivity, 20, "") if err != nil || len(items) != 1 || items[0].Title != "活动上线" || items[0].ActionType != "activity_detail" { t.Fatalf("user %d inbox mismatch after first batch: items=%+v err=%v", userID, items, err) } } now = now.Add(time.Second) processed, err = svc.ProcessNextFanoutJob(ctx, messageservice.FanoutWorkerOptions{WorkerID: "worker-a", BatchSize: 2, MaxRetry: 3, LockTTL: time.Minute}) if err != nil || !processed { t.Fatalf("second fanout batch failed: processed=%v err=%v", processed, err) } stored, exists, err = repository.GetFanoutJobByCommand(ctx, "cmd-fanout-worker-1") if err != nil || !exists || stored.Status != messagedomain.FanoutStatusComplete || stored.CursorUserID != 44 || stored.SuccessCount != 3 || stored.TotalCount != 3 || stored.AttemptCount != 2 { t.Fatalf("second batch progress mismatch: exists=%v job=%+v err=%v", exists, stored, err) } items, _, err := svc.ListInboxMessages(ctx, 44, messagedomain.SectionActivity, 20, "") if err != nil || len(items) != 1 || items[0].Summary != "新活动已上线" { t.Fatalf("last user inbox mismatch: items=%+v err=%v", items, err) } } func TestFanoutWorkerMaterializesAllRegisteredUsersViaTargetSource(t *testing.T) { ctx := appcode.WithContext(context.Background(), "lalu") repository := mysqltest.NewRepository(t) targets := &fakeFanoutTargetSource{userIDs: []int64{42, 43}} now := time.UnixMilli(1_800_000_040_000) svc := messageservice.New(messageservice.Config{NodeID: "activity-a"}, repository, messageservice.WithTargetSource(targets)) svc.SetClock(func() time.Time { return now }) job, created, err := svc.CreateFanoutJob(ctx, messagedomain.CreateFanoutInput{ CommandID: "cmd-fanout-registered-1", MessageType: messagedomain.SectionSystem, TargetScope: messagedomain.TargetScopeAllRegistered, TargetFilterJSON: `{}`, TemplateSnapshotJSON: `{"title":"系统通知","summary":"所有注册用户可见"}`, BatchSize: 500, CreatedBy: "admin:1", }) if err != nil || !created || job.TargetScope != messagedomain.TargetScopeAllRegistered { t.Fatalf("CreateFanoutJob failed: created=%v job=%+v err=%v", created, job, err) } processed, err := svc.ProcessNextFanoutJob(ctx, messageservice.FanoutWorkerOptions{WorkerID: "worker-a", BatchSize: 500, MaxRetry: 3, LockTTL: time.Minute}) if err != nil || !processed { t.Fatalf("fanout batch failed: processed=%v err=%v", processed, err) } if len(targets.queries) != 1 || targets.queries[0].TargetScope != messagedomain.TargetScopeAllRegistered { t.Fatalf("target source query mismatch: %+v", targets.queries) } for _, userID := range []int64{42, 43} { items, _, err := svc.ListInboxMessages(ctx, userID, messagedomain.SectionSystem, 20, "") if err != nil || len(items) != 1 || items[0].Title != "系统通知" { t.Fatalf("user %d inbox mismatch: items=%+v err=%v", userID, items, err) } } } type fakeFanoutTargetSource struct { queries []messageservice.TargetQuery userIDs []int64 } func (s *fakeFanoutTargetSource) ListFanoutUserIDs(_ context.Context, query messageservice.TargetQuery) ([]int64, int64, bool, error) { s.queries = append(s.queries, query) if len(s.userIDs) == 0 { return nil, query.CursorUserID, true, nil } nextCursor := s.userIDs[len(s.userIDs)-1] return append([]int64(nil), s.userIDs...), nextCursor, true, nil }