fix(voice): читать ответ GetParticipant в формате LiveKit 1.x
RoomService отвечает объектом участника на верхнем уровне, а не в обёртке
`{"participant": …}`: дорожка микрофона не находилась и серверный мьют молча
пропускался (AGENT.md 7.14). Обёртку тоже принимаем — на случай смены формы
ответа в новых версиях.
- `participantEnvelope` разбирает оба варианта;
- тесты `internal/voice/admin_test.go`: выбор дорожки микрофона (не камеры),
тело MutePublishedTrack, отсутствие микрофона, обёрнутый ответ,
RemoveParticipant и выключенный клиент; в каждом вызове проверяется
Bearer-токен, привязанный к комнате.
This commit is contained in:
+15
-5
@@ -48,16 +48,26 @@ type Participant struct {
|
|||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// participantEnvelope принимает оба формата ответа RoomService: LiveKit 1.x
|
||||||
|
// отдаёт объект участника на верхнем уровне (`GetParticipant`), а обёртку
|
||||||
|
// `{"participant": {...}}` поддерживаем на случай смены формы в новых версиях.
|
||||||
|
type participantEnvelope struct {
|
||||||
|
Participant
|
||||||
|
Wrapped *Participant `json:"participant"`
|
||||||
|
}
|
||||||
|
|
||||||
// ParticipantInfo отдаёт данные участника комнаты.
|
// ParticipantInfo отдаёт данные участника комнаты.
|
||||||
func (c *AdminClient) ParticipantInfo(ctx context.Context, room, identity string) (*Participant, error) {
|
func (c *AdminClient) ParticipantInfo(ctx context.Context, room, identity string) (*Participant, error) {
|
||||||
var response struct {
|
var envelope participantEnvelope
|
||||||
Participant Participant `json:"participant"`
|
err := c.call(ctx, room, "GetParticipant", map[string]any{"room": room, "identity": identity}, &envelope)
|
||||||
}
|
|
||||||
err := c.call(ctx, room, "GetParticipant", map[string]any{"room": room, "identity": identity}, &response)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &response.Participant, nil
|
if envelope.Wrapped != nil && envelope.Identity == "" && len(envelope.Tracks) == 0 {
|
||||||
|
return envelope.Wrapped, nil
|
||||||
|
}
|
||||||
|
participant := envelope.Participant
|
||||||
|
return &participant, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetMicrophoneMuted включает или выключает микрофон участника на стороне SFU:
|
// SetMicrophoneMuted включает или выключает микрофон участника на стороне SFU:
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package voice_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"glchat/internal/voice"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Тесты AdminClient: проверяют, что модерация действительно доходит до
|
||||||
|
// RoomService LiveKit — формат ответа `GetParticipant` (объект на верхнем
|
||||||
|
// уровне), имя дорожки микрофона и привязанный к комнате административный
|
||||||
|
// токен (AGENT.md 7.14).
|
||||||
|
|
||||||
|
// participantJSON — ответ LiveKit 1.9.7 на GetParticipant с дорожкой микрофона.
|
||||||
|
const participantJSON = `{
|
||||||
|
"sid": "PA_1",
|
||||||
|
"identity": "42",
|
||||||
|
"state": "ACTIVE",
|
||||||
|
"tracks": [
|
||||||
|
{"sid": "TR_CAM", "type": "VIDEO", "source": "CAMERA", "muted": false},
|
||||||
|
{"sid": "TR_MIC", "type": "AUDIO", "source": "MICROPHONE", "muted": false}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
// adminFixture поднимает фальшивый RoomService и возвращает клиента и журнал
|
||||||
|
// вызовов (метод → тело запроса).
|
||||||
|
func adminFixture(t *testing.T, getParticipantBody string) (*voice.AdminClient, *[]string, *[]map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
var methods []string
|
||||||
|
var bodies []map[string]any
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
method := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
|
||||||
|
raw, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("чтение тела: %v", err)
|
||||||
|
}
|
||||||
|
var body map[string]any
|
||||||
|
_ = json.Unmarshal(raw, &body)
|
||||||
|
methods = append(methods, method)
|
||||||
|
bodies = append(bodies, body)
|
||||||
|
if auth := r.Header.Get("Authorization"); !strings.HasPrefix(auth, "Bearer ") {
|
||||||
|
t.Errorf("нет Bearer-токена в %s: %q", method, auth)
|
||||||
|
} else if room := tokenRoom(t, strings.TrimPrefix(auth, "Bearer ")); room != "guild_1_channel_2" {
|
||||||
|
t.Errorf("токен %s привязан к комнате %q", method, room)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch method {
|
||||||
|
case "GetParticipant":
|
||||||
|
_, _ = w.Write([]byte(getParticipantBody))
|
||||||
|
default:
|
||||||
|
_, _ = w.Write([]byte(`{}`))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
client := voice.NewAdminClient(voice.NewIssuer("key123", "secret456", time.Hour), server.URL)
|
||||||
|
return client, &methods, &bodies
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenRoom достаёт имя комнаты из грахта административного токена.
|
||||||
|
func tokenRoom(t *testing.T, token string) string {
|
||||||
|
t.Helper()
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
t.Fatalf("JWT состоит из %d частей", len(parts))
|
||||||
|
}
|
||||||
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode payload: %v", err)
|
||||||
|
}
|
||||||
|
var claims struct {
|
||||||
|
Video struct {
|
||||||
|
Room string `json:"room"`
|
||||||
|
RoomAdmin bool `json:"roomAdmin"`
|
||||||
|
} `json:"video"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||||
|
t.Fatalf("decode claims: %v", err)
|
||||||
|
}
|
||||||
|
if !claims.Video.RoomAdmin {
|
||||||
|
t.Fatal("в токене нет права roomAdmin")
|
||||||
|
}
|
||||||
|
return claims.Video.Room
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSetMicrophoneMutedUsesMicTrack(t *testing.T) {
|
||||||
|
client, methods, bodies := adminFixture(t, participantJSON)
|
||||||
|
if err := client.SetMicrophoneMuted(context.Background(), "guild_1_channel_2", "42", true); err != nil {
|
||||||
|
t.Fatalf("SetMicrophoneMuted: %v", err)
|
||||||
|
}
|
||||||
|
if len(*methods) != 2 || (*methods)[0] != "GetParticipant" || (*methods)[1] != "MutePublishedTrack" {
|
||||||
|
t.Fatalf("вызовы = %v", *methods)
|
||||||
|
}
|
||||||
|
mute := (*bodies)[1]
|
||||||
|
if mute["track_sid"] != "TR_MIC" {
|
||||||
|
t.Fatalf("мьютим дорожку %v, ожидалась TR_MIC", mute["track_sid"])
|
||||||
|
}
|
||||||
|
if mute["muted"] != true || mute["identity"] != "42" || mute["room"] != "guild_1_channel_2" {
|
||||||
|
t.Fatalf("тело MutePublishedTrack = %v", mute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSetMicrophoneMutedWithoutMic(t *testing.T) {
|
||||||
|
client, methods, _ := adminFixture(t, `{"sid":"PA_1","identity":"42","tracks":[]}`)
|
||||||
|
if err := client.SetMicrophoneMuted(context.Background(), "guild_1_channel_2", "42", true); err != nil {
|
||||||
|
t.Fatalf("SetMicrophoneMuted: %v", err)
|
||||||
|
}
|
||||||
|
if len(*methods) != 1 {
|
||||||
|
t.Fatalf("без дорожки микрофона мьют не нужен, вызовы = %v", *methods)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminParticipantInfoAcceptsWrappedResponse(t *testing.T) {
|
||||||
|
client, _, _ := adminFixture(t, `{"participant":`+participantJSON+`}`)
|
||||||
|
participant, err := client.ParticipantInfo(context.Background(), "guild_1_channel_2", "42")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParticipantInfo: %v", err)
|
||||||
|
}
|
||||||
|
if participant.Identity != "42" || len(participant.Tracks) != 2 {
|
||||||
|
t.Fatalf("участник = %+v", participant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminRemoveParticipant(t *testing.T) {
|
||||||
|
client, methods, bodies := adminFixture(t, participantJSON)
|
||||||
|
if err := client.RemoveParticipant(context.Background(), "guild_1_channel_2", "42"); err != nil {
|
||||||
|
t.Fatalf("RemoveParticipant: %v", err)
|
||||||
|
}
|
||||||
|
if len(*methods) != 1 || (*methods)[0] != "RemoveParticipant" {
|
||||||
|
t.Fatalf("вызовы = %v", *methods)
|
||||||
|
}
|
||||||
|
if (*bodies)[0]["identity"] != "42" {
|
||||||
|
t.Fatalf("тело RemoveParticipant = %v", (*bodies)[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminClientDisabledWithoutURL(t *testing.T) {
|
||||||
|
client := voice.NewAdminClient(voice.NewIssuer("key", "secret", time.Hour), "")
|
||||||
|
if client.Enabled() {
|
||||||
|
t.Fatal("пустой адрес RoomService должен означать выключенную модерацию")
|
||||||
|
}
|
||||||
|
if err := client.RemoveParticipant(context.Background(), "guild_1_channel_2", "42"); err == nil {
|
||||||
|
t.Fatal("ожидалась ошибка при выключенном клиенте")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user