package broadcast import ( "context" "encoding/json" "errors" "testing" "time" "google.golang.org/protobuf/proto" roomeventsv1 "hyapp.local/api/proto/events/room/v1" "hyapp/pkg/appcode" "hyapp/pkg/tencentim" broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast" ) func TestHandleRoomGiftSentCreatesRegionBroadcast(t *testing.T) { repository := newFakeRepository() service := New(Config{NodeID: "node-a", SuperGiftMinValue: 100}, repository, nil, nil) service.SetClock(func() time.Time { return time.UnixMilli(1_700_000_000_000) }) envelope := mustGiftEnvelope(t, &roomeventsv1.RoomGiftSent{ SenderUserId: 42, TargetUserId: 43, GiftId: "rocket", GiftCount: 2, GiftValue: 999, VisibleRegionId: 1001, CommandId: "cmd-send-gift-1", }) result, err := service.HandleRoomEvent(appcode.WithContext(context.Background(), "lalu"), envelope) if err != nil { t.Fatalf("HandleRoomEvent failed: %v", err) } if result.Status != broadcastdomain.StatusPending || !result.BroadcastCreated || result.BroadcastEventID != "gift_broadcast:room-1001:cmd-send-gift-1" { t.Fatalf("unexpected result: %+v", result) } record := repository.records[result.BroadcastEventID] if record.GroupID != "hy_lalu_bc_r_1001" || record.BroadcastType != broadcastdomain.TypeSuperGift { t.Fatalf("unexpected broadcast record: %+v", record) } var payload map[string]any if err := json.Unmarshal([]byte(record.PayloadJSON), &payload); err != nil { t.Fatalf("payload is not json: %v", err) } if payload["room_id"] != "room-1001" || payload["gift_id"] != "rocket" || payload["scope"] != "region" { t.Fatalf("payload mismatch: %+v", payload) } again, err := service.HandleRoomEvent(appcode.WithContext(context.Background(), "lalu"), envelope) if err != nil { t.Fatalf("second HandleRoomEvent failed: %v", err) } if again.BroadcastCreated { t.Fatalf("second consume must be idempotent: %+v", again) } } func TestHandleRoomGiftSentSkipsNonBroadcastCases(t *testing.T) { service := New(Config{SuperGiftMinValue: 1000}, newFakeRepository(), nil, nil) belowThreshold := mustGiftEnvelope(t, &roomeventsv1.RoomGiftSent{GiftValue: 999, VisibleRegionId: 1001}) result, err := service.HandleRoomEvent(context.Background(), belowThreshold) if err != nil { t.Fatalf("below threshold failed: %v", err) } if result.Status != broadcastdomain.StatusSkipped || result.BroadcastEventID != "" { t.Fatalf("below threshold should skip: %+v", result) } globalRegion := mustGiftEnvelope(t, &roomeventsv1.RoomGiftSent{GiftValue: 1000, VisibleRegionId: 0}) result, err = service.HandleRoomEvent(context.Background(), globalRegion) if err != nil { t.Fatalf("global region failed: %v", err) } if result.Status != broadcastdomain.StatusSkipped || result.BroadcastEventID != "" { t.Fatalf("global region should skip: %+v", result) } } func TestProcessPendingBroadcastsPublishesCustomMessage(t *testing.T) { repository := newFakeRepository() publisher := &fakePublisher{} service := New(Config{NodeID: "node-a"}, repository, publisher, nil) repository.records["evt-1"] = broadcastdomain.OutboxRecord{ AppCode: "lalu", EventID: "evt-1", GroupID: "hy_lalu_bc_g", BroadcastType: broadcastdomain.TypeRedPacket, PayloadJSON: `{"event_id":"evt-1","broadcast_type":"red_packet"}`, Status: broadcastdomain.StatusPending, } result, err := service.ProcessPendingBroadcasts(appcode.WithContext(context.Background(), "lalu"), WorkerOptions{WorkerID: "worker-a", BatchSize: 10}) if err != nil { t.Fatalf("ProcessPendingBroadcasts failed: %v", err) } if result.SuccessCount != 1 || len(publisher.messages) != 1 { t.Fatalf("publish result mismatch: result=%+v messages=%d", result, len(publisher.messages)) } if repository.records["evt-1"].Status != broadcastdomain.StatusDelivered { t.Fatalf("record should be delivered: %+v", repository.records["evt-1"]) } if publisher.messages[0].GroupID != "hy_lalu_bc_g" || publisher.messages[0].Desc != broadcastdomain.TypeRedPacket { t.Fatalf("message mismatch: %+v", publisher.messages[0]) } } func TestRemoveRegionBroadcastMemberDeletesOnlyUserMembership(t *testing.T) { publisher := &fakePublisher{} service := New(Config{NodeID: "node-a"}, newFakeRepository(), publisher, nil) groupID, removed, err := service.RemoveRegionBroadcastMember(appcode.WithContext(context.Background(), "lalu"), 42, 1001) if err != nil { t.Fatalf("RemoveRegionBroadcastMember failed: %v", err) } if !removed || groupID != "hy_lalu_bc_r_1001" { t.Fatalf("unexpected remove result: group_id=%s removed=%t", groupID, removed) } if publisher.deletedGroupID != "hy_lalu_bc_r_1001" || publisher.deletedUserID != 42 { t.Fatalf("unexpected deleted member: group_id=%s user_id=%d", publisher.deletedGroupID, publisher.deletedUserID) } groupID, removed, err = service.RemoveRegionBroadcastMember(appcode.WithContext(context.Background(), "lalu"), 42, 0) if err != nil { t.Fatalf("zero region should be a no-op, got: %v", err) } if removed || groupID != "" { t.Fatalf("zero region should not delete any group member: group_id=%s removed=%t", groupID, removed) } } func mustGiftEnvelope(t *testing.T, gift *roomeventsv1.RoomGiftSent) *roomeventsv1.EventEnvelope { t.Helper() body, err := proto.Marshal(gift) if err != nil { t.Fatalf("marshal gift failed: %v", err) } return &roomeventsv1.EventEnvelope{ EventId: "evt-room-gift-1", RoomId: "room-1001", EventType: "RoomGiftSent", RoomVersion: 7, OccurredAtMs: 1_700_000_000_000, AppCode: "lalu", Body: body, } } type fakeRepository struct { records map[string]broadcastdomain.OutboxRecord } func newFakeRepository() *fakeRepository { return &fakeRepository{records: map[string]broadcastdomain.OutboxRecord{}} } func (r *fakeRepository) SaveBroadcastOutbox(_ context.Context, record broadcastdomain.OutboxRecord) (bool, broadcastdomain.OutboxRecord, error) { if existing, ok := r.records[record.EventID]; ok { return false, existing, nil } r.records[record.EventID] = record return true, record, nil } func (r *fakeRepository) ClaimPendingBroadcastOutbox(_ context.Context, workerID string, _ int64, lockUntilMS int64, limit int, _ int) ([]broadcastdomain.OutboxRecord, error) { records := make([]broadcastdomain.OutboxRecord, 0, limit) for eventID, record := range r.records { if len(records) >= limit { break } if record.Status != broadcastdomain.StatusPending && record.Status != broadcastdomain.StatusRetryable { continue } record.Status = broadcastdomain.StatusDelivering record.LockedBy = workerID record.LockedUntilMS = lockUntilMS r.records[eventID] = record records = append(records, record) } return records, nil } func (r *fakeRepository) MarkBroadcastOutboxDelivered(_ context.Context, eventID string, nowMS int64) error { record, ok := r.records[eventID] if !ok { return errors.New("record not found") } record.Status = broadcastdomain.StatusDelivered record.UpdatedAtMS = nowMS r.records[eventID] = record return nil } func (r *fakeRepository) MarkBroadcastOutboxFailed(_ context.Context, eventID string, _ string, nowMS int64, nextRetryAtMS int64, _ int, lastError string) error { record, ok := r.records[eventID] if !ok { return errors.New("record not found") } record.Status = broadcastdomain.StatusRetryable record.AttemptCount++ record.NextRetryAtMS = nextRetryAtMS record.LastError = lastError record.UpdatedAtMS = nowMS r.records[eventID] = record return nil } type fakePublisher struct { messages []tencentim.CustomGroupMessage deletedGroupID string deletedUserID int64 } func (p *fakePublisher) EnsureGroup(context.Context, tencentim.GroupSpec) error { return nil } func (p *fakePublisher) PublishGroupCustomMessage(_ context.Context, message tencentim.CustomGroupMessage) error { p.messages = append(p.messages, message) return nil } func (p *fakePublisher) DeleteGroupMember(_ context.Context, groupID string, userID int64) error { p.deletedGroupID = groupID p.deletedUserID = userID return nil }