228 lines
8.2 KiB
Go
228 lines
8.2 KiB
Go
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())
|
|
}
|
|
}
|