346 lines
16 KiB
Go
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
|
|
}
|