package service_test import ( "context" "testing" "time" roomv1 "hyapp.local/api/proto/room/v1" "hyapp/services/room-service/internal/testutil/mysqltest" ) 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 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 }