2026-07-06 12:43:40 +08:00

346 lines
16 KiB
Go

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
}