2026-04-25 01:18:30 +08:00

240 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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)
}