773 lines
29 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
import (
"context"
"errors"
"fmt"
"sync"
"testing"
"time"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/integration"
"hyapp/services/room-service/internal/room/command"
roomservice "hyapp/services/room-service/internal/room/service"
"hyapp/services/room-service/internal/router"
"hyapp/services/room-service/internal/testutil/mysqltest"
)
type recordingRTCUserAudioBlocker struct {
mu sync.Mutex
calls []bool
err error
}
func (b *recordingRTCUserAudioBlocker) SetUserAudioBlockedByStrRoomID(_ context.Context, _ string, _ int64, blocked bool) error {
b.mu.Lock()
defer b.mu.Unlock()
b.calls = append(b.calls, blocked)
return b.err
}
func (b *recordingRTCUserAudioBlocker) snapshot() []bool {
b.mu.Lock()
defer b.mu.Unlock()
return append([]bool(nil), b.calls...)
}
func TestMutedUserCannotSpeakOrMicUpUntilUnmuted(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 7, 21, 8, 0, 0, 0, time.UTC)}
rtcBlocker := &recordingRTCUserAudioBlocker{}
svc := roomservice.New(roomservice.Config{
NodeID: "node-muted-rtc-test",
LeaseTTL: 10 * time.Second,
RankLimit: 20,
SnapshotEveryN: 1,
Clock: now,
RTCUserAudioBlocker: rtcBlocker,
}, router.NewMemoryDirectory(), repository, &rocketTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
roomID := "room-muted-user-guard"
ownerID := int64(8481)
mutedUserID := int64(8482)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9101)
joinRocketRoom(t, ctx, svc, roomID, mutedUserID)
if _, err := svc.MuteUser(ctx, &roomv1.MuteUserRequest{
Meta: rocketMeta(roomID, ownerID, "mute-user-guard"),
TargetUserId: mutedUserID,
Muted: true,
}); err != nil {
t.Fatalf("mute user failed: %v", err)
}
mutedSpeak, err := svc.CheckSpeakPermission(ctx, &roomv1.CheckSpeakPermissionRequest{
RoomId: roomID,
UserId: mutedUserID,
AppCode: appcode.Default,
})
if err != nil {
t.Fatalf("check muted speak permission failed: %v", err)
}
if mutedSpeak.GetAllowed() || mutedSpeak.GetReason() != "user_muted" {
t.Fatalf("muted user must be rejected by public chat guard: %+v", mutedSpeak)
}
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
Meta: rocketMeta(roomID, mutedUserID, "muted-mic-up"),
SeatNo: 2,
}); !xerr.IsCode(err, xerr.PermissionDenied) {
t.Fatalf("muted user mic up must return permission denied: %v", err)
}
if _, err := svc.MuteUser(ctx, &roomv1.MuteUserRequest{
Meta: rocketMeta(roomID, ownerID, "unmute-user-guard"),
TargetUserId: mutedUserID,
Muted: false,
}); err != nil {
t.Fatalf("unmute user failed: %v", err)
}
unmutedSpeak, err := svc.CheckSpeakPermission(ctx, &roomv1.CheckSpeakPermissionRequest{
RoomId: roomID,
UserId: mutedUserID,
AppCode: appcode.Default,
})
if err != nil {
t.Fatalf("check unmuted speak permission failed: %v", err)
}
if !unmutedSpeak.GetAllowed() || unmutedSpeak.GetReason() != "" {
t.Fatalf("unmuted user must recover public chat permission: %+v", unmutedSpeak)
}
if _, err := svc.MuteUser(ctx, &roomv1.MuteUserRequest{
Meta: rocketMeta(roomID, ownerID, "mute-user-guard"),
TargetUserId: mutedUserID,
Muted: true,
}); err != nil {
t.Fatalf("old mute command replay failed: %v", err)
}
if got := rtcBlocker.snapshot(); len(got) != 3 || got[2] {
// pipeline 会向重放返回第一次 mute 的历史 result.snapshotRTC 投影
// 必须忽略它并重读 newer unmute否则重试会把云端永久回退为禁音。
t.Fatalf("old mute replay must reconcile latest unmuted state: %+v", got)
}
upResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
Meta: rocketMeta(roomID, mutedUserID, "unmuted-mic-up"),
SeatNo: 2,
})
if err != nil {
t.Fatalf("unmuted user must recover mic up permission: %v", err)
}
mutedOnSeat, err := svc.MuteUser(ctx, &roomv1.MuteUserRequest{
Meta: rocketMeta(roomID, ownerID, "mute-current-rtc-seat"),
TargetUserId: mutedUserID,
Muted: true,
})
if err != nil {
t.Fatalf("mute current RTC seat failed: %v", err)
}
var currentSeat *roomv1.SeatState
for _, seat := range mutedOnSeat.GetRoom().GetMicSeats() {
if seat.GetUserId() == mutedUserID {
currentSeat = seat
break
}
}
if currentSeat == nil || !currentSeat.GetMicMuted() || currentSeat.GetSeatNo() != 2 {
t.Fatalf("room mute must project to the current RTC seat without removing it: %+v", currentSeat)
}
if _, err := svc.SetMicMute(ctx, &roomv1.SetMicMuteRequest{
Meta: rocketMeta(roomID, mutedUserID, "muted-user-open-mic"),
TargetUserId: mutedUserID,
Muted: false,
}); !xerr.IsCode(err, xerr.PermissionDenied) {
t.Fatalf("room-muted user must not reopen RTC mic: %v", err)
}
if _, err := svc.ConfirmMicPublishing(ctx, &roomv1.ConfirmMicPublishingRequest{
Meta: rocketMeta(roomID, mutedUserID, "muted-publish-confirm"),
TargetUserId: mutedUserID,
MicSessionId: upResp.GetMicSessionId(),
RoomVersion: upResp.GetResult().GetRoomVersion(),
EventTimeMs: now.Now().UnixMilli(),
Source: "client",
}); !xerr.IsCode(err, xerr.PermissionDenied) {
t.Fatalf("room mute between MicUp and RTC confirmation must reject publishing: %v", err)
}
rtcEventResp, err := svc.ApplyRTCEvent(ctx, &roomv1.ApplyRTCEventRequest{
Meta: rocketMeta(roomID, mutedUserID, "muted-rtc-audio-started"),
TargetUserId: mutedUserID,
EventType: "audio_started",
EventTimeMs: now.Now().UnixMilli(),
Source: "tencent_rtc_callback",
ExternalEventId: "rtc-event-muted-audio-started",
})
if err != nil {
t.Fatalf("muted RTC audio callback must be acknowledged after cloud re-block: %v", err)
}
if rtcEventResp.GetResult().GetApplied() {
t.Fatalf("muted RTC audio callback must not advance the seat to publishing: %+v", rtcEventResp)
}
if _, err := svc.MuteUser(ctx, &roomv1.MuteUserRequest{
Meta: rocketMeta(roomID, ownerID, "unmute-current-rtc-seat"),
TargetUserId: mutedUserID,
Muted: false,
}); err != nil {
t.Fatalf("unmute current RTC seat failed: %v", err)
}
if _, err := svc.SetMicMute(ctx, &roomv1.SetMicMuteRequest{
Meta: rocketMeta(roomID, mutedUserID, "unmuted-user-open-mic"),
TargetUserId: mutedUserID,
Muted: false,
}); err != nil {
t.Fatalf("room unmute must restore explicit RTC mic control: %v", err)
}
directCalls := rtcBlocker.snapshot()
if len(directCalls) != 6 || !directCalls[0] || directCalls[1] || directCalls[2] || !directCalls[3] || !directCalls[4] || directCalls[5] {
// 四次新 MuteUser 分别投影 true/false/true/false旧 mute 重放必须仍投影 false
// 中间被篡改客户端触发的
// audio_started 必须额外 re-block=true因此按调用顺序断言服务端防线完整。
t.Fatalf("unexpected direct RTC audio block sequence: %+v", directCalls)
}
if err := svc.ProcessPendingOutbox(ctx, roomservice.OutboxWorkerOptions{PublishTimeout: time.Second, BatchSize: 100}); err != nil {
t.Fatalf("process RTC mute outbox failed: %v", err)
}
allCalls := rtcBlocker.snapshot()
if len(allCalls)-len(directCalls) != 4 {
t.Fatalf("each durable RoomUserMuted event must reach RTC compensation: direct=%+v all=%+v", directCalls, allCalls)
}
for index, blocked := range allCalls[len(directCalls):] {
// outbox 中既有 mute 也有 unmute 历史事件;补偿必须读取当前已解除的 Room Cell
// 状态并全部收敛到 false不能按旧事件载荷重新把用户禁言。
if blocked {
t.Fatalf("stale RTC mute outbox call %d rolled current state backward", index)
}
}
}
type blockingRTCUserAudioBlocker struct {
mu sync.Mutex
calls []bool
blocked bool
firstStarted chan struct{}
releaseFirst chan struct{}
}
func newBlockingRTCUserAudioBlocker() *blockingRTCUserAudioBlocker {
return &blockingRTCUserAudioBlocker{firstStarted: make(chan struct{}), releaseFirst: make(chan struct{})}
}
func (b *blockingRTCUserAudioBlocker) SetUserAudioBlockedByStrRoomID(ctx context.Context, _ string, _ int64, blocked bool) error {
b.mu.Lock()
b.calls = append(b.calls, blocked)
first := len(b.calls) == 1
b.mu.Unlock()
if first {
close(b.firstStarted)
select {
case <-ctx.Done():
return ctx.Err()
case <-b.releaseFirst:
}
}
b.mu.Lock()
b.blocked = blocked
b.mu.Unlock()
return nil
}
func (b *blockingRTCUserAudioBlocker) snapshot() ([]bool, bool) {
b.mu.Lock()
defer b.mu.Unlock()
return append([]bool(nil), b.calls...), b.blocked
}
func TestRTCUserAudioReconcileConvergesSlowMuteAndNewerUnmute(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 7, 21, 9, 0, 0, 0, time.UTC)}
rtcBlocker := newBlockingRTCUserAudioBlocker()
svc := roomservice.New(roomservice.Config{
NodeID: "node-muted-rtc-race-test",
LeaseTTL: 10 * time.Second,
SnapshotEveryN: 1,
Clock: now,
RTCUserAudioBlocker: rtcBlocker,
}, router.NewMemoryDirectory(), repository, &rocketTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
roomID := "room-muted-rtc-race"
ownerID := int64(8491)
targetUserID := int64(8492)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9101)
joinRocketRoom(t, ctx, svc, roomID, targetUserID)
muteDone := make(chan error, 1)
go func() {
_, err := svc.MuteUser(ctx, &roomv1.MuteUserRequest{
Meta: rocketMeta(roomID, ownerID, "slow-mute"),
TargetUserId: targetUserID,
Muted: true,
})
muteDone <- err
}()
select {
case <-rtcBlocker.firstStarted:
case <-time.After(2 * time.Second):
t.Fatal("slow mute never reached RTC blocker")
}
unmuteDone := make(chan error, 1)
go func() {
_, err := svc.MuteUser(ctx, &roomv1.MuteUserRequest{
Meta: rocketMeta(roomID, ownerID, "fast-unmute"),
TargetUserId: targetUserID,
Muted: false,
})
unmuteDone <- err
}()
deadline := time.Now().Add(2 * time.Second)
for {
permission, err := svc.CheckSpeakPermission(ctx, &roomv1.CheckSpeakPermissionRequest{RoomId: roomID, UserId: targetUserID, AppCode: appcode.Default})
if err == nil && permission.GetAllowed() {
break
}
if time.Now().After(deadline) {
t.Fatalf("newer unmute did not commit while slow RTC mute was in flight: permission=%+v err=%v", permission, err)
}
time.Sleep(5 * time.Millisecond)
}
close(rtcBlocker.releaseFirst)
if err := <-muteDone; err != nil {
t.Fatalf("slow mute request failed: %v", err)
}
if err := <-unmuteDone; err != nil {
t.Fatalf("newer unmute request failed: %v", err)
}
calls, blocked := rtcBlocker.snapshot()
if blocked || len(calls) < 2 || !calls[0] || calls[len(calls)-1] {
t.Fatalf("RTC projection must finish at newer unmuted state: calls=%+v blocked=%t", calls, blocked)
}
}
func TestRTCUserAudioFailureDoesNotBlockRoomMuteOutboxPublisher(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 7, 21, 10, 0, 0, 0, time.UTC)}
rtcBlocker := &recordingRTCUserAudioBlocker{err: errors.New("rtc unavailable")}
outboxPublisher := newRecordingRoomDirectIMPublisher()
svc := roomservice.New(roomservice.Config{
NodeID: "node-muted-rtc-outbox-test",
LeaseTTL: 10 * time.Second,
SnapshotEveryN: 1,
Clock: now,
RTCUserAudioBlocker: rtcBlocker,
}, router.NewMemoryDirectory(), repository, &rocketTestWallet{}, integration.NewNoopRoomEventPublisher(), outboxPublisher)
roomID := "room-muted-rtc-outbox"
ownerID := int64(8493)
targetUserID := int64(8494)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9101)
joinRocketRoom(t, ctx, svc, roomID, targetUserID)
if _, err := svc.MuteUser(ctx, &roomv1.MuteUserRequest{
Meta: rocketMeta(roomID, ownerID, "mute-with-rtc-failure"),
TargetUserId: targetUserID,
Muted: true,
}); err != nil {
t.Fatalf("durable room mute must succeed despite direct RTC failure: %v", err)
}
if err := svc.ProcessPendingOutbox(ctx, roomservice.OutboxWorkerOptions{
PublishTimeout: time.Second,
BatchSize: 100,
MaxRetryCount: 3,
InitialBackoff: time.Second,
MaxBackoff: time.Second,
}); err != nil {
t.Fatalf("process room outbox with RTC failure: %v", err)
}
if outboxPublisher.counts()["RoomUserMuted"] != 1 {
t.Fatalf("RTC failure must not prevent generic RoomUserMuted publication: counts=%+v", outboxPublisher.counts())
}
}
func TestMicUpReturnsExistingSeatForDuplicateUserCommand(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 4, 8, 0, 0, 0, time.UTC)}
svc := newRocketTestService(t, repository, &rocketTestWallet{}, now)
roomID := "room-mic-up-duplicate-user"
ownerID := int64(8501)
speakerID := int64(8502)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9101)
joinRocketRoom(t, ctx, svc, roomID, speakerID)
upResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
Meta: rocketMeta(roomID, speakerID, "mic-up-duplicate-user-first"),
SeatNo: 2,
})
if err != nil {
t.Fatalf("first mic up failed: %v", err)
}
duplicateResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
Meta: rocketMeta(roomID, speakerID, "mic-up-duplicate-user-second"),
SeatNo: 3,
})
if err != nil {
t.Fatalf("duplicate mic up must return current session instead of conflict: %v", err)
}
if duplicateResp.GetResult().GetApplied() {
t.Fatalf("duplicate mic up must be a no-op mutation: %+v", duplicateResp.GetResult())
}
if duplicateResp.GetRoom().GetVersion() != upResp.GetRoom().GetVersion() {
t.Fatalf("duplicate mic up must not advance room version: got=%d want=%d", duplicateResp.GetRoom().GetVersion(), upResp.GetRoom().GetVersion())
}
if duplicateResp.GetSeatNo() != 2 || duplicateResp.GetMicSessionId() != upResp.GetMicSessionId() || duplicateResp.GetPublishDeadlineMs() != upResp.GetPublishDeadlineMs() {
t.Fatalf("duplicate mic up must return original mic session: duplicate=%+v first=%+v", duplicateResp, upResp)
}
if originalSeat := seatByNo(duplicateResp.GetRoom(), 2); originalSeat == nil || originalSeat.GetUserId() != speakerID || originalSeat.GetMicSessionId() != upResp.GetMicSessionId() {
t.Fatalf("original seat must remain occupied by speaker: %+v", originalSeat)
}
if requestedSeat := seatByNo(duplicateResp.GetRoom(), 3); requestedSeat == nil || requestedSeat.GetUserId() != 0 {
// 重复 MicUp 只能表达“我已经在麦上”的幂等结果;换到 seat 3 必须走 ChangeMicSeat。
t.Fatalf("duplicate mic up must not move user to requested seat: %+v", requestedSeat)
}
}
func TestConfirmMicPublishingAcceptsFastClientClockBeforeServerDeadline(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 4, 8, 0, 0, 0, time.UTC)}
svc := newRocketTestService(t, repository, &rocketTestWallet{}, now)
roomID := "room-mic-confirm-fast-client-clock"
ownerID := int64(8551)
speakerID := int64(8552)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9101)
joinRocketRoom(t, ctx, svc, roomID, speakerID)
upResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
Meta: rocketMeta(roomID, speakerID, "mic-confirm-fast-clock-up"),
SeatNo: 2,
})
if err != nil {
t.Fatalf("mic up failed: %v", err)
}
arrivedAtMS := upResp.GetPublishDeadlineMs() - 1
fastClientEventMS := upResp.GetPublishDeadlineMs() + int64((5 * time.Second).Milliseconds())
now.now = time.UnixMilli(arrivedAtMS)
confirmMeta := rocketMeta(roomID, speakerID, "mic-confirm-fast-clock-confirm")
confirmResp, err := svc.ConfirmMicPublishing(ctx, &roomv1.ConfirmMicPublishingRequest{
Meta: confirmMeta,
MicSessionId: upResp.GetMicSessionId(),
RoomVersion: upResp.GetRoom().GetVersion(),
EventTimeMs: fastClientEventMS,
Source: "client",
})
if err != nil {
t.Fatalf("confirm mic publishing failed: %v", err)
}
if !confirmResp.GetResult().GetApplied() {
t.Fatalf("fast client event_time_ms must not be treated as timeout before server deadline: %+v", confirmResp.GetResult())
}
seat := seatByNo(confirmResp.GetRoom(), 2)
if seat == nil || seat.GetPublishState() != "publishing" {
t.Fatalf("seat must be confirmed as publishing: %+v", seat)
}
if seat.GetLastPublishEventTimeMs() != arrivedAtMS || seat.GetMicHeartbeatAtMs() != arrivedAtMS {
t.Fatalf("confirmation must store server accepted time, not fast client event time: seat=%+v accepted=%d client_event=%d", seat, arrivedAtMS, fastClientEventMS)
}
record, exists, err := repository.GetCommand(appcode.WithContext(ctx, appcode.Default), roomID, confirmMeta.GetCommandId())
if err != nil {
t.Fatalf("get confirm command log failed: %v", err)
}
if !exists {
t.Fatalf("confirm command log must be persisted")
}
decoded, err := command.Deserialize(command.ConfirmMicPublishing{}.Type(), record.Payload)
if err != nil {
t.Fatalf("decode confirm command log failed: %v", err)
}
confirmCmd, ok := decoded.(*command.ConfirmMicPublishing)
if !ok {
t.Fatalf("confirm command log has unexpected type: %T", decoded)
}
if confirmCmd.EventTimeMS != fastClientEventMS || confirmCmd.AcceptedEventTimeMS != arrivedAtMS {
t.Fatalf("confirm command log must keep raw client time and accepted server time: %+v raw=%d accepted=%d", confirmCmd, fastClientEventMS, arrivedAtMS)
}
}
func TestConfirmMicPublishingIgnoresStaleSessionVersionAndTimeout(t *testing.T) {
tests := []struct {
name string
adjust func(clock *fixedRoomRocketClock, upResp *roomv1.MicUpResponse)
request func(roomID string, speakerID int64, clock *fixedRoomRocketClock, upResp *roomv1.MicUpResponse) *roomv1.ConfirmMicPublishingRequest
}{
{
name: "old_session",
adjust: func(clock *fixedRoomRocketClock, _ *roomv1.MicUpResponse) {
clock.now = clock.now.Add(500 * time.Millisecond)
},
request: func(roomID string, speakerID int64, clock *fixedRoomRocketClock, upResp *roomv1.MicUpResponse) *roomv1.ConfirmMicPublishingRequest {
return &roomv1.ConfirmMicPublishingRequest{
Meta: rocketMeta(roomID, speakerID, "mic-confirm-old-session"),
MicSessionId: upResp.GetMicSessionId() + "-old",
RoomVersion: upResp.GetRoom().GetVersion(),
EventTimeMs: clock.Now().UnixMilli(),
Source: "client",
}
},
},
{
name: "old_room_version",
adjust: func(clock *fixedRoomRocketClock, _ *roomv1.MicUpResponse) {
clock.now = clock.now.Add(500 * time.Millisecond)
},
request: func(roomID string, speakerID int64, clock *fixedRoomRocketClock, upResp *roomv1.MicUpResponse) *roomv1.ConfirmMicPublishingRequest {
return &roomv1.ConfirmMicPublishingRequest{
Meta: rocketMeta(roomID, speakerID, "mic-confirm-old-room-version"),
MicSessionId: upResp.GetMicSessionId(),
RoomVersion: upResp.GetRoom().GetVersion() - 1,
EventTimeMs: clock.Now().UnixMilli(),
Source: "client",
}
},
},
{
name: "deadline_reached",
adjust: func(clock *fixedRoomRocketClock, upResp *roomv1.MicUpResponse) {
clock.now = time.UnixMilli(upResp.GetPublishDeadlineMs())
},
request: func(roomID string, speakerID int64, _ *fixedRoomRocketClock, upResp *roomv1.MicUpResponse) *roomv1.ConfirmMicPublishingRequest {
return &roomv1.ConfirmMicPublishingRequest{
Meta: rocketMeta(roomID, speakerID, "mic-confirm-deadline-reached"),
MicSessionId: upResp.GetMicSessionId(),
RoomVersion: upResp.GetRoom().GetVersion(),
EventTimeMs: upResp.GetPublishDeadlineMs() - 1,
Source: "client",
}
},
},
}
for index, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 4, 8, 0, 0, 0, time.UTC)}
svc := newRocketTestService(t, repository, &rocketTestWallet{}, now)
roomID := fmt.Sprintf("room-mic-confirm-guard-%d", index)
ownerID := int64(8561 + index*10)
speakerID := int64(8562 + index*10)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9101)
joinRocketRoom(t, ctx, svc, roomID, speakerID)
upResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
Meta: rocketMeta(roomID, speakerID, fmt.Sprintf("mic-confirm-guard-up-%d", index)),
SeatNo: 2,
})
if err != nil {
t.Fatalf("mic up failed: %v", err)
}
tt.adjust(now, upResp)
confirmResp, err := svc.ConfirmMicPublishing(ctx, tt.request(roomID, speakerID, now, upResp))
if err != nil {
t.Fatalf("stale confirm must be ignored without error: %v", err)
}
if confirmResp.GetResult().GetApplied() || confirmResp.GetRoom().GetVersion() != upResp.GetRoom().GetVersion() {
t.Fatalf("stale confirm must not advance room state: confirm=%+v up_version=%d", confirmResp.GetResult(), upResp.GetRoom().GetVersion())
}
seat := seatByNo(confirmResp.GetRoom(), 2)
if seat == nil || seat.GetPublishState() != "pending_publish" || seat.GetLastPublishEventTimeMs() != 0 || seat.GetMicHeartbeatAtMs() != 0 {
t.Fatalf("stale confirm must leave pending session untouched: %+v", seat)
}
})
}
}
func TestMicHeartbeatRefreshesPublishingSessionAndPresence(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 4, 8, 0, 0, 0, time.UTC)}
svc := newRocketTestService(t, repository, &rocketTestWallet{}, now)
roomID := "room-mic-heartbeat"
ownerID := int64(8601)
speakerID := int64(8602)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9101)
joinRocketRoom(t, ctx, svc, roomID, speakerID)
upResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
Meta: rocketMeta(roomID, speakerID, "mic-heartbeat-up"),
SeatNo: 2,
})
if err != nil {
t.Fatalf("mic up failed: %v", err)
}
now.now = now.now.Add(500 * time.Millisecond)
confirmResp, err := svc.ConfirmMicPublishing(ctx, &roomv1.ConfirmMicPublishingRequest{
Meta: rocketMeta(roomID, speakerID, "mic-heartbeat-confirm"),
MicSessionId: upResp.GetMicSessionId(),
RoomVersion: upResp.GetRoom().GetVersion(),
EventTimeMs: now.Now().UnixMilli(),
Source: "client",
})
if err != nil {
t.Fatalf("confirm mic publishing failed: %v", err)
}
confirmedSeat := seatByNo(confirmResp.GetRoom(), 2)
if confirmedSeat == nil || confirmedSeat.GetPublishState() != "publishing" || confirmedSeat.GetMicHeartbeatAtMs() == 0 {
t.Fatalf("publish confirmation must initialize heartbeat: %+v", confirmedSeat)
}
now.now = now.now.Add(3 * time.Second)
heartbeatAtMS := now.Now().UnixMilli()
heartbeatResp, err := svc.MicHeartbeat(ctx, &roomv1.MicHeartbeatRequest{
Meta: rocketMeta(roomID, speakerID, "mic-heartbeat-refresh"),
MicSessionId: upResp.GetMicSessionId(),
})
if err != nil {
t.Fatalf("mic heartbeat failed: %v", err)
}
if heartbeatResp.GetSeatNo() != 2 || heartbeatResp.GetMicHeartbeatAtMs() != heartbeatAtMS {
t.Fatalf("heartbeat response mismatch: %+v want_at=%d", heartbeatResp, heartbeatAtMS)
}
seat := seatByNo(heartbeatResp.GetRoom(), 2)
if seat == nil || seat.GetMicHeartbeatAtMs() != heartbeatAtMS {
t.Fatalf("seat heartbeat must be visible in snapshot: %+v", seat)
}
user := onlineUserByID(heartbeatResp.GetRoom(), speakerID)
if user == nil || user.GetLastSeenAtMs() != heartbeatAtMS {
t.Fatalf("mic heartbeat must refresh room presence: %+v", user)
}
}
func TestConfirmMicPublishingIgnoresBackwardEventTime(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 4, 8, 0, 0, 0, time.UTC)}
svc := newRocketTestService(t, repository, &rocketTestWallet{}, now)
roomID := "room-mic-confirm-backward-event"
ownerID := int64(8651)
speakerID := int64(8652)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9101)
joinRocketRoom(t, ctx, svc, roomID, speakerID)
upResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
Meta: rocketMeta(roomID, speakerID, "mic-confirm-backward-up"),
SeatNo: 2,
})
if err != nil {
t.Fatalf("mic up failed: %v", err)
}
now.now = now.now.Add(time.Second)
acceptedEventMS := now.Now().UnixMilli()
confirmResp, err := svc.ConfirmMicPublishing(ctx, &roomv1.ConfirmMicPublishingRequest{
Meta: rocketMeta(roomID, speakerID, "mic-confirm-backward-first"),
MicSessionId: upResp.GetMicSessionId(),
RoomVersion: upResp.GetRoom().GetVersion(),
EventTimeMs: acceptedEventMS,
Source: "client",
})
if err != nil {
t.Fatalf("first confirm failed: %v", err)
}
if !confirmResp.GetResult().GetApplied() {
t.Fatalf("first confirm must apply: %+v", confirmResp.GetResult())
}
now.now = now.now.Add(time.Second)
backwardResp, err := svc.ConfirmMicPublishing(ctx, &roomv1.ConfirmMicPublishingRequest{
Meta: rocketMeta(roomID, speakerID, "mic-confirm-backward-retry"),
MicSessionId: upResp.GetMicSessionId(),
RoomVersion: confirmResp.GetRoom().GetVersion(),
EventTimeMs: acceptedEventMS - 1,
Source: "client",
})
if err != nil {
t.Fatalf("backward confirm must be ignored without error: %v", err)
}
if backwardResp.GetResult().GetApplied() || backwardResp.GetRoom().GetVersion() != confirmResp.GetRoom().GetVersion() {
t.Fatalf("backward event must not advance room state: backward=%+v confirmed_version=%d", backwardResp.GetResult(), confirmResp.GetRoom().GetVersion())
}
seat := seatByNo(backwardResp.GetRoom(), 2)
if seat == nil || seat.GetPublishState() != "publishing" || seat.GetLastPublishEventTimeMs() != acceptedEventMS {
t.Fatalf("backward event must not lower accepted event time: %+v want_event=%d", seat, acceptedEventMS)
}
}
func TestRTCAudioStoppedReleasesAfterFastClientConfirmWatermark(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 4, 8, 0, 0, 0, time.UTC)}
svc := newRocketTestService(t, repository, &rocketTestWallet{}, now)
roomID := "room-rtc-stop-after-fast-confirm"
ownerID := int64(8661)
speakerID := int64(8662)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9101)
joinRocketRoom(t, ctx, svc, roomID, speakerID)
upResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
Meta: rocketMeta(roomID, speakerID, "rtc-stop-after-fast-confirm-up"),
SeatNo: 2,
})
if err != nil {
t.Fatalf("mic up failed: %v", err)
}
createdAtMS := upResp.GetPublishDeadlineMs() - int64((15 * time.Second).Milliseconds())
stopEventMS := createdAtMS + int64((2 * time.Second).Milliseconds())
acceptedAtMS := upResp.GetPublishDeadlineMs() - 1
now.now = time.UnixMilli(acceptedAtMS)
confirmResp, err := svc.ConfirmMicPublishing(ctx, &roomv1.ConfirmMicPublishingRequest{
Meta: rocketMeta(roomID, speakerID, "rtc-stop-after-fast-confirm-confirm"),
MicSessionId: upResp.GetMicSessionId(),
RoomVersion: upResp.GetRoom().GetVersion(),
EventTimeMs: upResp.GetPublishDeadlineMs() + 5000,
Source: "client",
})
if err != nil {
t.Fatalf("confirm mic publishing failed: %v", err)
}
confirmedSeat := seatByNo(confirmResp.GetRoom(), 2)
if confirmedSeat == nil || confirmedSeat.GetLastPublishEventTimeMs() != acceptedAtMS {
t.Fatalf("confirm must store accepted watermark before RTC stop test: %+v accepted=%d", confirmedSeat, acceptedAtMS)
}
now.now = now.now.Add(time.Second)
stopResp, err := svc.ApplyRTCEvent(ctx, &roomv1.ApplyRTCEventRequest{
Meta: rocketMeta(roomID, speakerID, "rtc-stop-after-fast-confirm-stop"),
TargetUserId: speakerID,
EventType: "audio_stopped",
EventTimeMs: stopEventMS,
Reason: "rtc_audio_stopped:0",
Source: "tencent_rtc_callback",
})
if err != nil {
t.Fatalf("rtc audio stopped failed: %v", err)
}
if !stopResp.GetResult().GetApplied() {
t.Fatalf("rtc stop must release current mic session even when provider event time is below client confirm accepted time: %+v", stopResp.GetResult())
}
seat := seatByNo(stopResp.GetRoom(), 2)
if seat == nil || seat.GetUserId() != 0 || seat.GetMicSessionId() != "" {
t.Fatalf("rtc stop must clear current mic seat: %+v", seat)
}
}
func TestMicHeartbeatRejectsPendingSession(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 4, 8, 0, 0, 0, time.UTC)}
svc := newRocketTestService(t, repository, &rocketTestWallet{}, now)
roomID := "room-mic-heartbeat-pending"
ownerID := int64(8701)
speakerID := int64(8702)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9101)
joinRocketRoom(t, ctx, svc, roomID, speakerID)
upResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
Meta: rocketMeta(roomID, speakerID, "mic-heartbeat-pending-up"),
SeatNo: 1,
})
if err != nil {
t.Fatalf("mic up failed: %v", err)
}
if _, err := svc.MicHeartbeat(ctx, &roomv1.MicHeartbeatRequest{
Meta: rocketMeta(roomID, speakerID, "mic-heartbeat-pending-refresh"),
MicSessionId: upResp.GetMicSessionId(),
}); err == nil {
t.Fatalf("pending_publish session must not accept mic heartbeat before publish confirmation")
}
}
func seatByNo(snapshot *roomv1.RoomSnapshot, seatNo int32) *roomv1.SeatState {
for _, seat := range snapshot.GetMicSeats() {
if seat.GetSeatNo() == seatNo {
return seat
}
}
return nil
}
func onlineUserByID(snapshot *roomv1.RoomSnapshot, userID int64) *roomv1.RoomUser {
for _, user := range snapshot.GetOnlineUsers() {
if user.GetUserId() == userID {
return user
}
}
return nil
}