// Package service_test 验证 im-service 领域层的房间消息、守卫、幂等和 ACK 行为。 package service_test import ( "context" "fmt" "testing" "time" imv1 "hyapp/api/proto/im/v1" roomv1 "hyapp/api/proto/room/v1" "hyapp/services/im-service/internal/auth" imservice "hyapp/services/im-service/internal/service" ) // fakeGuard 代替 room-service guard RPC,让测试只关注 im-service 的领域逻辑。 type fakeGuard struct { // speakAllowed 按 room_id:user_id 控制公屏发言是否允许。 speakAllowed map[string]bool // speakReason 保存发言拒绝原因,模拟 room-service 返回的业务原因。 speakReason map[string]string // present 按 room_id:user_id 控制用户是否仍在房间 presence 内。 present map[string]bool // presentReason 保存订阅拒绝原因,模拟被踢或未进房场景。 presentReason map[string]string } // newFakeGuard 默认放行发言和 presence,单个测试只覆盖自己关心的拒绝分支。 func newFakeGuard() *fakeGuard { return &fakeGuard{ speakAllowed: make(map[string]bool), speakReason: make(map[string]string), present: make(map[string]bool), presentReason: make(map[string]string), } } // CheckSpeakPermission 实现 service.GuardClient,缺省允许发言以减少无关测试准备。 func (g *fakeGuard) CheckSpeakPermission(_ context.Context, req *roomv1.CheckSpeakPermissionRequest) (*roomv1.CheckSpeakPermissionResponse, error) { key := guardKey(req.GetRoomId(), req.GetUserId()) allowed, exists := g.speakAllowed[key] if !exists { // 未配置的用户默认允许,测试显式配置时才触发拒绝分支。 allowed = true } return &roomv1.CheckSpeakPermissionResponse{ Allowed: allowed, Reason: g.speakReason[key], }, nil } // VerifyRoomPresence 实现 service.GuardClient,缺省认为用户仍在房间内。 func (g *fakeGuard) VerifyRoomPresence(_ context.Context, req *roomv1.VerifyRoomPresenceRequest) (*roomv1.VerifyRoomPresenceResponse, error) { key := guardKey(req.GetRoomId(), req.GetUserId()) present, exists := g.present[key] if !exists { // 未配置的用户默认在房间内,避免订阅守卫干扰消息链路测试。 present = true } return &roomv1.VerifyRoomPresenceResponse{ Present: present, Reason: g.presentReason[key], }, nil } // TestIMRoomFlowHistoryAndEventDedup 覆盖房间订阅、系统事件幂等、公屏 room_seq 和历史窗口。 func TestIMRoomFlowHistoryAndEventDedup(t *testing.T) { ctx := context.Background() guard := newFakeGuard() verifier := auth.NewHMACVerifier("im-secret") svc := imservice.New(imservice.Config{ NodeID: "im-node-a", RoomHistoryLimit: 200, }, verifier, guard) token1, _ := verifier.Mint(1, "ios", time.Hour) token2, _ := verifier.Mint(2, "android", time.Hour) conn1, out1, err := svc.Connect(token1) if err != nil { t.Fatalf("Connect user1 failed: %v", err) } conn2, out2, err := svc.Connect(token2) if err != nil { t.Fatalf("Connect user2 failed: %v", err) } if err := svc.SubscribeRoom(ctx, conn1, "room-1"); err != nil { t.Fatalf("SubscribeRoom user1 failed: %v", err) } if err := svc.SubscribeRoom(ctx, conn2, "room-1"); err != nil { t.Fatalf("SubscribeRoom user2 failed: %v", err) } // 第一次 room-service 事件应写入房间统一时间线并广播给订阅连接。 firstPublish, err := svc.PublishRoomEvent(ctx, &imv1.PublishRoomEventRequest{ Event: &imv1.RoomEvent{ EventId: "evt-1", RoomId: "room-1", EventType: "room_mic_down", }, }) if err != nil { t.Fatalf("PublishRoomEvent failed: %v", err) } // 相同 event_id 代表 outbox 补投或同步重试,必须复用原 room_seq 和 message_id。 duplicatePublish, err := svc.PublishRoomEvent(ctx, &imv1.PublishRoomEventRequest{ Event: &imv1.RoomEvent{ EventId: "evt-1", RoomId: "room-1", EventType: "room_mic_down", }, }) if err != nil { t.Fatalf("duplicate PublishRoomEvent failed: %v", err) } if firstPublish.GetRoomSeq() != duplicatePublish.GetRoomSeq() || firstPublish.GetMessageId() != duplicatePublish.GetMessageId() { t.Fatalf("duplicate event should reuse same message identity") } // 公屏消息和房间系统事件共享 room_seq,因此第一条公屏应排在第二位。 chatMessage, err := svc.SendRoomChat(ctx, conn1, "room-1", "hello") if err != nil { t.Fatalf("SendRoomChat failed: %v", err) } if chatMessage.RoomSeq != 2 { t.Fatalf("room seq mismatch: got %d want 2", chatMessage.RoomSeq) } history, err := svc.PullRoomHistory(conn1, "room-1", 10) if err != nil { t.Fatalf("PullRoomHistory failed: %v", err) } if len(history) != 2 { t.Fatalf("history length mismatch: got %d want 2", len(history)) } // 两个订阅用户都应收到广播帧;这里读取任一帧确认投递路径可用。 readFrame(t, out1) frame2 := readFrame(t, out2) if frame2.Message == nil || frame2.Message.MessageType == "" { t.Fatalf("expected broadcast frame, got %+v", frame2) } } // TestIMMutedUserCannotSendRoomChat 验证 im-service 不绕过 room-service 禁言守卫。 func TestIMMutedUserCannotSendRoomChat(t *testing.T) { ctx := context.Background() guard := newFakeGuard() guard.speakAllowed[guardKey("room-2", 1)] = false guard.speakReason[guardKey("room-2", 1)] = "user_muted" verifier := auth.NewHMACVerifier("im-secret") svc := imservice.New(imservice.Config{ NodeID: "im-node-a", RoomHistoryLimit: 200, }, verifier, guard) token, _ := verifier.Mint(1, "ios", time.Hour) connID, _, err := svc.Connect(token) if err != nil { t.Fatalf("Connect failed: %v", err) } if err := svc.SubscribeRoom(ctx, connID, "room-2"); err != nil { t.Fatalf("SubscribeRoom failed: %v", err) } // CheckSpeakPermission 返回拒绝后,消息不能进入房间时间线。 if _, err := svc.SendRoomChat(ctx, connID, "room-2", "blocked"); err == nil { t.Fatalf("expected muted user send to fail") } } // TestIMPeerMessageACK 验证单聊消息会建立接收方 ACK 待确认项,并且只能由接收方清理。 func TestIMPeerMessageACK(t *testing.T) { guard := newFakeGuard() verifier := auth.NewHMACVerifier("im-secret") svc := imservice.New(imservice.Config{ NodeID: "im-node-a", RoomHistoryLimit: 200, }, verifier, guard) token1, _ := verifier.Mint(1, "ios", time.Hour) token2, _ := verifier.Mint(2, "android", time.Hour) conn1, _, err := svc.Connect(token1) if err != nil { t.Fatalf("Connect sender failed: %v", err) } conn2, _, err := svc.Connect(token2) if err != nil { t.Fatalf("Connect receiver failed: %v", err) } message, err := svc.SendPeerMessage(conn1, 2, "ping") if err != nil { t.Fatalf("SendPeerMessage failed: %v", err) } if pending := svc.PendingACK(2); pending != 1 { t.Fatalf("pending ack mismatch before ack: got %d want 1", pending) } // 接收方 ACK 后 pending 集合应清空,证明 ACK 归属绑定 user_id 生效。 if err := svc.Ack(conn2, message.MessageID); err != nil { t.Fatalf("Ack failed: %v", err) } if pending := svc.PendingACK(2); pending != 0 { t.Fatalf("pending ack mismatch after ack: got %d want 0", pending) } } // readFrame 从测试下行队列读取一帧,超时直接失败以暴露广播未投递问题。 func readFrame(t *testing.T, out <-chan imservice.OutgoingFrame) imservice.OutgoingFrame { t.Helper() select { case frame := <-out: return frame case <-time.After(2 * time.Second): t.Fatalf("timeout waiting for frame") return imservice.OutgoingFrame{} } } // guardKey 生成 fakeGuard 的稳定复合 key,避免测试里散落字符串拼接规则。 func guardKey(roomID string, userID int64) string { return fmt.Sprintf("%s:%d", roomID, userID) }