240 lines
7.6 KiB
Go
240 lines
7.6 KiB
Go
// 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)
|
||
}
|