diff --git a/internal/voice/admin.go b/internal/voice/admin.go index f522b10..22df1db 100644 --- a/internal/voice/admin.go +++ b/internal/voice/admin.go @@ -48,16 +48,26 @@ type Participant struct { } `json:"tracks"` } +// participantEnvelope принимает оба формата ответа RoomService: LiveKit 1.x +// отдаёт объект участника на верхнем уровне (`GetParticipant`), а обёртку +// `{"participant": {...}}` поддерживаем на случай смены формы в новых версиях. +type participantEnvelope struct { + Participant + Wrapped *Participant `json:"participant"` +} + // ParticipantInfo отдаёт данные участника комнаты. func (c *AdminClient) ParticipantInfo(ctx context.Context, room, identity string) (*Participant, error) { - var response struct { - Participant Participant `json:"participant"` - } - err := c.call(ctx, room, "GetParticipant", map[string]any{"room": room, "identity": identity}, &response) + var envelope participantEnvelope + err := c.call(ctx, room, "GetParticipant", map[string]any{"room": room, "identity": identity}, &envelope) if err != nil { 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: diff --git a/internal/voice/admin_test.go b/internal/voice/admin_test.go new file mode 100644 index 0000000..3dd30ce --- /dev/null +++ b/internal/voice/admin_test.go @@ -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("ожидалась ошибка при выключенном клиенте") + } +}