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:
2026-09-20 19:59:04 +03:00
parent a6d23a295e
commit 4ba449670a
2 changed files with 167 additions and 5 deletions
+152
View File
@@ -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("ожидалась ошибка при выключенном клиенте")
}
}