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

313 lines
10 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 验证 room-service 的 Room Cell 命令链路、恢复和 outbox 补偿。
package service_test
import (
"context"
"testing"
"time"
roomeventsv1 "hyapp/api/proto/events/room/v1"
imv1 "hyapp/api/proto/im/v1"
roomv1 "hyapp/api/proto/room/v1"
walletv1 "hyapp/api/proto/wallet/v1"
roomservice "hyapp/services/room-service/internal/room/service"
"hyapp/services/room-service/internal/router"
"hyapp/services/room-service/internal/storage/memory"
)
// fakeWallet 模拟 wallet-service 扣费成功路径。
type fakeWallet struct{}
// DebitGift 返回可预测账单回执和礼物总价值,测试只关注 room-service 状态变更。
func (fakeWallet) DebitGift(_ context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) {
return &walletv1.DebitGiftResponse{
BillingReceiptId: "receipt_" + req.GetCommandId(),
TotalGiftValue: int64(req.GetGiftCount()) * req.GetGiftUnitValue(),
BalanceAfter: 1000,
}, nil
}
// fakeSyncPublisher 记录 room-service 同步推给 im-service 的房间事件。
type fakeSyncPublisher struct {
// fail 为 true 时模拟 im-service PublishRoomEvent 失败。
fail bool
// events 保存已尝试同步发布的事件。
events []*imv1.RoomEvent
}
// PublishRoomEvent 记录事件,并按 fail 开关返回错误。
func (p *fakeSyncPublisher) PublishRoomEvent(_ context.Context, event *imv1.RoomEvent) error {
p.events = append(p.events, event)
if p.fail {
return context.DeadlineExceeded
}
return nil
}
// fakeOutboxPublisher 记录 outbox worker 补偿投递的事件信封。
type fakeOutboxPublisher struct {
// envelopes 保存补偿发布过的 protobuf 信封。
envelopes []*roomeventsv1.EventEnvelope
}
// PublishOutboxEvent 记录补偿事件,模拟外部消费者投递成功。
func (p *fakeOutboxPublisher) PublishOutboxEvent(_ context.Context, envelope *roomeventsv1.EventEnvelope) error {
p.envelopes = append(p.envelopes, envelope)
return nil
}
// newRoomService 组装带内存 repository/directory 的 room-service便于测试 Room Cell 语义。
func newRoomService(nodeID string, snapshotEveryN int64, directory router.Directory, repository *memory.Repository, syncPublisher *fakeSyncPublisher, outboxPublisher *fakeOutboxPublisher) *roomservice.Service {
return roomservice.New(roomservice.Config{
NodeID: nodeID,
LeaseTTL: 10 * time.Second,
RankLimit: 20,
SnapshotEveryN: snapshotEveryN,
}, directory, repository, fakeWallet{}, syncPublisher, outboxPublisher)
}
// TestRoomLifecycleGiftAndGuards 覆盖创建、进房、上麦、送礼、禁言和踢人的主链路。
func TestRoomLifecycleGiftAndGuards(t *testing.T) {
ctx := context.Background()
repository := memory.NewRepository()
directory := router.NewMemoryDirectory()
syncPublisher := &fakeSyncPublisher{}
outboxPublisher := &fakeOutboxPublisher{}
svc := newRoomService("node-a", 1, directory, repository, syncPublisher, outboxPublisher)
roomID := "room-1001"
// 创建房间会初始化 owner presence、麦位、snapshot、command log 和 RoomCreated outbox。
createResp, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
OwnerUserId: 1,
HostUserId: 1,
SeatCount: 4,
Mode: "social",
})
if err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if !createResp.GetResult().GetApplied() {
t.Fatalf("CreateRoom should apply")
}
// JoinRoom 只进入 room-service 业务 presence不涉及 im-service WS 订阅。
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 2),
Role: "audience",
}); err != nil {
t.Fatalf("JoinRoom failed: %v", err)
}
// MicUp 验证 Room Cell 内麦位占用和版本变更。
micResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
Meta: roomservice.NewRequestMeta(roomID, 2),
SeatNo: 1,
})
if err != nil {
t.Fatalf("MicUp failed: %v", err)
}
if micResp.GetSeatNo() != 1 {
t.Fatalf("MicUp seat mismatch: got %d", micResp.GetSeatNo())
}
// SendGift 先走 fakeWallet再更新热度和房间内本地礼物榜。
giftResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
TargetUserId: 2,
GiftId: "rose",
GiftCount: 2,
GiftUnitValue: 10,
})
if err != nil {
t.Fatalf("SendGift failed: %v", err)
}
if got := giftResp.GetRoomHeat(); got != 20 {
t.Fatalf("room heat mismatch: got %d want 20", got)
}
if len(giftResp.GetGiftRank()) != 1 || giftResp.GetGiftRank()[0].GetUserId() != 1 {
t.Fatalf("gift rank mismatch: %+v", giftResp.GetGiftRank())
}
if len(syncPublisher.events) == 0 || syncPublisher.events[len(syncPublisher.events)-1].GetEventType() != "room_gift_sent" {
t.Fatalf("expected room_gift_sent sync publish, got %+v", syncPublisher.events)
}
// MuteUser 后 CheckSpeakPermission 必须拒绝 im-service 的公屏发送请求。
if _, err := svc.MuteUser(ctx, &roomv1.MuteUserRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
TargetUserId: 2,
Muted: true,
}); err != nil {
t.Fatalf("MuteUser failed: %v", err)
}
speakResp, err := svc.CheckSpeakPermission(ctx, &roomv1.CheckSpeakPermissionRequest{
RoomId: roomID,
UserId: 2,
})
if err != nil {
t.Fatalf("CheckSpeakPermission failed: %v", err)
}
if speakResp.GetAllowed() || speakResp.GetReason() != "user_muted" {
t.Fatalf("unexpected speak guard response: %+v", speakResp)
}
// KickUser 后 VerifyRoomPresence 必须拒绝 im-service 的 subscribe_room 请求。
if _, err := svc.KickUser(ctx, &roomv1.KickUserRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
TargetUserId: 2,
}); err != nil {
t.Fatalf("KickUser failed: %v", err)
}
presenceResp, err := svc.VerifyRoomPresence(ctx, &roomv1.VerifyRoomPresenceRequest{
RoomId: roomID,
UserId: 2,
})
if err != nil {
t.Fatalf("VerifyRoomPresence failed: %v", err)
}
if presenceResp.GetPresent() || presenceResp.GetReason() != "user_banned" {
t.Fatalf("unexpected presence guard response: %+v", presenceResp)
}
}
// TestRoomRecoveryAfterLeaseTakeover 验证 lease 过期后新节点通过 snapshot + command log 恢复房间。
func TestRoomRecoveryAfterLeaseTakeover(t *testing.T) {
ctx := context.Background()
repository := memory.NewRepository()
directory := router.NewMemoryDirectory()
syncPublisherA := &fakeSyncPublisher{}
outboxPublisher := &fakeOutboxPublisher{}
serviceA := newRoomService("node-a", 2, directory, repository, syncPublisherA, outboxPublisher)
roomID := "room-recover"
// node-a 先创建并推进房间状态snapshotEveryN=2 会让恢复需要结合快照和命令日志。
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
OwnerUserId: 1,
HostUserId: 1,
SeatCount: 4,
Mode: "social",
}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 2),
Role: "audience",
}); err != nil {
t.Fatalf("JoinRoom failed: %v", err)
}
if _, err := serviceA.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
TargetUserId: 2,
GiftId: "rose",
GiftCount: 1,
GiftUnitValue: 10,
}); err != nil {
t.Fatalf("SendGift failed: %v", err)
}
// 强制过期模拟 node-a 故障后 Redis lease 失效。
directory.ForceExpire(roomID)
// node-b 收到命令时应先接管 lease再恢复 Room Cell而不是返回 room not found。
serviceB := newRoomService("node-b", 2, directory, repository, &fakeSyncPublisher{}, outboxPublisher)
joinResp, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 3),
Role: "audience",
})
if err != nil {
t.Fatalf("JoinRoom on takeover node failed: %v", err)
}
if got := joinResp.GetRoom().GetHeat(); got != 10 {
t.Fatalf("recovered heat mismatch: got %d want 10", got)
}
if len(joinResp.GetRoom().GetGiftRank()) != 1 || joinResp.GetRoom().GetGiftRank()[0].GetUserId() != 1 {
t.Fatalf("recovered rank mismatch: %+v", joinResp.GetRoom().GetGiftRank())
}
lease, exists, err := directory.Lookup(ctx, roomID)
if err != nil || !exists {
t.Fatalf("Lookup failed: %v exists=%v", err, exists)
}
if lease.NodeID != "node-b" {
t.Fatalf("lease takeover failed: owner=%s", lease.NodeID)
}
}
// TestRoomOutboxCompensationWhenSyncPublishFails 验证同步广播失败不回滚房间状态outbox 可补偿。
func TestRoomOutboxCompensationWhenSyncPublishFails(t *testing.T) {
ctx := context.Background()
repository := memory.NewRepository()
directory := router.NewMemoryDirectory()
syncPublisher := &fakeSyncPublisher{fail: true}
outboxPublisher := &fakeOutboxPublisher{}
svc := newRoomService("node-a", 1, directory, repository, syncPublisher, outboxPublisher)
roomID := "room-outbox"
// 创建和进房先建立可送礼的基本房间态。
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
OwnerUserId: 1,
HostUserId: 1,
SeatCount: 4,
Mode: "social",
}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 2),
Role: "audience",
}); err != nil {
t.Fatalf("JoinRoom failed: %v", err)
}
if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
TargetUserId: 2,
GiftId: "car",
GiftCount: 1,
GiftUnitValue: 100,
}); err != nil {
t.Fatalf("SendGift failed: %v", err)
}
// fakeSyncPublisher 失败后,房间事件仍应作为 pending outbox 记录保留。
pendingBefore, err := repository.ListPendingOutbox(ctx, 100)
if err != nil {
t.Fatalf("ListPendingOutbox failed: %v", err)
}
if len(pendingBefore) == 0 {
t.Fatalf("expected pending outbox records before compensation")
}
// 补偿 worker 成功投递后 fakeOutboxPublisher 应记录事件信封。
if err := svc.ProcessPendingOutbox(ctx, 100); err != nil {
t.Fatalf("ProcessPendingOutbox failed: %v", err)
}
if len(outboxPublisher.envelopes) == 0 {
t.Fatalf("expected compensated outbox publish")
}
}