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("ожидалась ошибка при выключенном клиенте") } }