313 lines
10 KiB
Go
313 lines
10 KiB
Go
// 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")
|
||
}
|
||
}
|