package service_test import ( "context" "testing" "time" roomv1 "hyapp.local/api/proto/room/v1" "hyapp/pkg/appcode" "hyapp/pkg/xerr" "hyapp/services/room-service/internal/integration" roomservice "hyapp/services/room-service/internal/room/service" "hyapp/services/room-service/internal/router" "hyapp/services/room-service/internal/testutil/mysqltest" ) func newHeartbeatRecoveryService(t *testing.T, repository *mysqltest.Repository, nodeID string, clk *fixedRoomRocketClock) *roomservice.Service { t.Helper() cfg := roomservice.Config{ NodeID: nodeID, LeaseTTL: 10 * time.Second, RankLimit: 20, SnapshotEveryN: 1, } if clk != nil { cfg.Clock = clk } return roomservice.New(cfg, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) } func createHeartbeatRecoveryRoom(t *testing.T, svc *roomservice.Service, roomID string, ownerID int64, shortID string) { t.Helper() if _, err := svc.CreateRoom(context.Background(), &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, ownerID), SeatCount: 10, Mode: "voice", VisibleRegionId: 8101, RoomName: "Heartbeat Recovery", RoomAvatar: testRoomCoverURL, RoomShortId: shortID, }); err != nil { t.Fatalf("create heartbeat recovery room fixture failed: %v", err) } } func TestRoomHeartbeatRecoversClearedPresence(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) svc := newHeartbeatRecoveryService(t, repository, "node-hb-recover-test", nil) roomID := "room-hb-recover" ownerID := int64(7501) viewerID := int64(7601) createHeartbeatRecoveryRoom(t, svc, roomID, ownerID, "hb-recover") if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, viewerID), Role: "audience", }); err != nil { t.Fatalf("join viewer failed: %v", err) } // LeaveRoom 与 stale sweep 走同一条 presence 删除路径,等价模拟"服务端单方面清掉 presence"。 if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, viewerID), }); err != nil { t.Fatalf("leave viewer failed: %v", err) } outboxBefore := repository.CountRowsByRoom("room_outbox", roomID) heartbeatResp, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{ Meta: roomservice.NewRequestMeta(roomID, viewerID), }) if err != nil { t.Fatalf("heartbeat must recover cleared presence: %v", err) } recovered := findRoomUser(heartbeatResp.GetRoom(), viewerID) if recovered == nil || recovered.GetRole() != "audience" { t.Fatalf("recovered presence must be back online as audience: %+v", heartbeatResp.GetRoom()) } if outboxAfter := repository.CountRowsByRoom("room_outbox", roomID); outboxAfter != outboxBefore { // 恢复必须静默:不发 RoomUserJoined 等 durable outbox 事件,避免重复入房横幅与统计重复计数。 t.Fatalf("silent recovery must not emit outbox events: before=%d after=%d", outboxBefore, outboxAfter) } if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{ Meta: roomservice.NewRequestMeta(roomID, viewerID), SeatNo: 1, }); err != nil { t.Fatalf("mic up must succeed right after heartbeat recovery: %v", err) } } func TestRoomHeartbeatKeepsRejectingKickedUser(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) svc := newHeartbeatRecoveryService(t, repository, "node-hb-kick-guard-test", nil) roomID := "room-hb-kick-guard" ownerID := int64(7701) viewerID := int64(7801) createHeartbeatRecoveryRoom(t, svc, roomID, ownerID, "hb-kick-guard") if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, viewerID), Role: "audience", }); err != nil { t.Fatalf("join viewer failed: %v", err) } if _, err := svc.KickUser(ctx, &roomv1.KickUserRequest{ Meta: roomservice.NewRequestMeta(roomID, ownerID), TargetUserId: viewerID, }); err != nil { t.Fatalf("kick viewer failed: %v", err) } _, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{ Meta: roomservice.NewRequestMeta(roomID, viewerID), }) if err == nil || xerr.CodeOf(err) != xerr.PermissionDenied { t.Fatalf("kicked user heartbeat must stay PermissionDenied, got: %v", err) } presenceResp, err := svc.VerifyRoomPresence(ctx, &roomv1.VerifyRoomPresenceRequest{ AppCode: appcode.Default, RoomId: roomID, UserId: viewerID, }) if err != nil { t.Fatalf("verify presence after rejected heartbeat failed: %v", err) } if presenceResp.GetPresent() || presenceResp.GetReason() != "user_banned" { t.Fatalf("kicked user must not regain presence through heartbeat: %+v", presenceResp) } } func TestRoomHeartbeatRecoveryAfterKickExpires(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) clk := &fixedRoomRocketClock{now: time.Date(2026, 7, 13, 8, 0, 0, 0, time.UTC)} svc := newHeartbeatRecoveryService(t, repository, "node-hb-kick-expire-test", clk) roomID := "room-hb-kick-expire" ownerID := int64(7901) viewerID := int64(8001) createHeartbeatRecoveryRoom(t, svc, roomID, ownerID, "hb-kick-expire") if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, viewerID), Role: "audience", }); err != nil { t.Fatalf("join viewer failed: %v", err) } if _, err := svc.KickUser(ctx, &roomv1.KickUserRequest{ Meta: roomservice.NewRequestMeta(roomID, ownerID), TargetUserId: viewerID, DurationMs: 1000, }); err != nil { t.Fatalf("duration kick failed: %v", err) } if _, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{ Meta: roomservice.NewRequestMeta(roomID, viewerID), }); err == nil || xerr.CodeOf(err) != xerr.PermissionDenied { t.Fatalf("heartbeat inside ban window must stay PermissionDenied, got: %v", err) } clk.now = clk.now.Add(2 * time.Second) heartbeatResp, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{ Meta: roomservice.NewRequestMeta(roomID, viewerID), }) if err != nil { t.Fatalf("heartbeat after ban expiry must recover presence: %v", err) } if findRoomUser(heartbeatResp.GetRoom(), viewerID) == nil { t.Fatalf("expired ban user must be back online: %+v", heartbeatResp.GetRoom()) } } func TestRoomHeartbeatDoesNotBypassRoomPassword(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) svc := newHeartbeatRecoveryService(t, repository, "node-hb-password-test", nil) roomID := "room-hb-password" ownerID := int64(8101) viewerID := int64(8201) createHeartbeatRecoveryRoom(t, svc, roomID, ownerID, "hb-password") if _, err := svc.SetRoomPassword(ctx, &roomv1.SetRoomPasswordRequest{ Meta: roomservice.NewRequestMeta(roomID, ownerID), Locked: true, Password: "4321", }); err != nil { t.Fatalf("set room password failed: %v", err) } if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, viewerID), Role: "audience", Password: "4321", }); err != nil { t.Fatalf("join locked room with password failed: %v", err) } if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, viewerID), }); err != nil { t.Fatalf("leave viewer failed: %v", err) } // 心跳不携带密码,非房主不得借心跳绕过锁房,必须维持原 NotFound 契约走完整 JoinRoom。 if _, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{ Meta: roomservice.NewRequestMeta(roomID, viewerID), }); err == nil || xerr.CodeOf(err) != xerr.NotFound { t.Fatalf("locked room heartbeat recovery must stay NotFound for non-owner, got: %v", err) } // 房主不受锁房密码约束(与 JoinRoom 一致),恢复后角色收敛为 owner。 if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, ownerID), }); err != nil { t.Fatalf("leave owner failed: %v", err) } heartbeatResp, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{ Meta: roomservice.NewRequestMeta(roomID, ownerID), }) if err != nil { t.Fatalf("owner heartbeat recovery in locked room failed: %v", err) } owner := findRoomUser(heartbeatResp.GetRoom(), ownerID) if owner == nil || owner.GetRole() != "owner" { t.Fatalf("owner recovery must keep owner role: %+v", heartbeatResp.GetRoom()) } }