Files
Playa/tests/Volcanic.AgenticCore.Telegram.Tests/OpenAiSpeechToTextTests.cs
EugeneTes 6ab98a8155 fix(telegram): address review findings
- A speech or download timeout (TaskCanceledException nobody asked for) is a failure
  with the user notice, not a silent shutdown; OpenAiSpeechToText maps it to
  "the request timed out."; UpdateRouter only treats the caller's cancellation as shutdown.
- send: WorkspacePaths resolves the real path component by component (realpath),
  closing the directory-link-inside-target bypass; `.` reports a directory.
- Image documents are attached inline only when their magic bytes are JPEG/PNG/GIF/WebP,
  under the detected type.
- Audio goes to STT as an ASCII audio.<ext> name.
- MCP Browser section mentions send only when it is active.
- RunInOrderAsync takes a cancellation token; queued work is skipped once disposal gives up;
  SendReplyAsync stays private behind ReplyInOrderAsync.
- Speech HttpClient handler recycles pooled connections; BuiltInTools.Send();
  IExtensionHost.Channel is abstract; WorkspacePaths in its own file.
- Tests: timeouts, symlink chains (Linux), sniffing, channel delivery failures,
  queued-work cancellation, gated voice ordering. Spec and docs updated.
2026-09-26 22:35:20 +02:00

122 lines
6.0 KiB
C#

using System.Net;
using System.Text;
using Volcanic.AgenticCore.Telegram.Speech;
namespace Volcanic.AgenticCore.Telegram.Tests;
public sealed class OpenAiSpeechToTextTests
{
private const string Key = "sk-test-secret-key-123";
private sealed record Part(string? FileName, string? ContentType, string Text);
private sealed class StubHandler(HttpStatusCode status, string body, string mediaType = "application/json") : HttpMessageHandler
{
public HttpRequestMessage? Request { get; private set; }
public Dictionary<string, Part> Parts { get; } = [];
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Request = request;
foreach (var part in (MultipartFormDataContent)request.Content!)
{
var disposition = part.Headers.ContentDisposition!;
Parts[disposition.Name!.Trim('"')] = new Part(disposition.FileName?.Trim('"'), part.Headers.ContentType?.MediaType,
await part.ReadAsStringAsync(cancellationToken));
}
return new HttpResponseMessage(status) { Content = new StringContent(body, Encoding.UTF8, mediaType) };
}
}
private static (OpenAiSpeechToText Stt, StubHandler Handler) Create(HttpStatusCode status, string body, string? language = null)
{
var handler = new StubHandler(status, body);
var options = new SpeechOptions { ApiKey = Key, Language = language, BaseUrl = "https://stt.example/v1/" };
return (new OpenAiSpeechToText(new HttpClient(handler), options), handler);
}
[Fact]
public async Task Posts_a_multipart_form_with_file_model_and_response_format()
{
var (stt, handler) = Create(HttpStatusCode.OK, "{\"text\":\"hello\"}");
await stt.TranscribeAsync(new byte[] { 1, 2, 3 }, "voice.ogg", "audio/ogg", CancellationToken.None);
Assert.Equal(HttpMethod.Post, handler.Request!.Method);
Assert.Equal("https://stt.example/v1/audio/transcriptions", handler.Request.RequestUri!.ToString());
Assert.Equal("Bearer", handler.Request.Headers.Authorization!.Scheme);
Assert.Equal(Key, handler.Request.Headers.Authorization.Parameter);
Assert.Equal(("voice.ogg", "audio/ogg"), (handler.Parts["file"].FileName, handler.Parts["file"].ContentType));
Assert.Equal("gpt-4o-transcribe", handler.Parts["model"].Text);
Assert.Equal("json", handler.Parts["response_format"].Text);
Assert.False(handler.Parts.ContainsKey("language"));
}
[Fact]
public async Task Language_is_sent_only_when_configured()
{
var (stt, handler) = Create(HttpStatusCode.OK, "{\"text\":\"hallo\"}", language: "de");
await stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", CancellationToken.None);
Assert.Equal("de", handler.Parts["language"].Text);
}
[Fact]
public async Task The_transcript_is_trimmed()
{
var (stt, _) = Create(HttpStatusCode.OK, "{\"text\":\" hello there \\n\"}");
Assert.Equal("hello there", await stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", CancellationToken.None));
}
[Fact]
public async Task A_non_success_status_names_the_status_and_hides_the_key()
{
var body = "{\"error\":{\"message\":\"Incorrect API key provided: " + Key + "\"}}" + new string('x', 300);
var (stt, _) = Create(HttpStatusCode.Unauthorized, body);
var ex = await Assert.ThrowsAsync<SpeechToTextException>(() => stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", CancellationToken.None));
Assert.StartsWith("OpenAI transcription failed: HTTP 401 ", ex.Message, StringComparison.Ordinal);
Assert.DoesNotContain(Key, ex.Message, StringComparison.Ordinal);
Assert.True(ex.Message.Length <= "OpenAI transcription failed: HTTP 401 ".Length + 200);
}
[Fact]
public async Task A_non_json_body_has_its_own_message()
{
var (stt, _) = Create(HttpStatusCode.OK, "<html>gateway</html>");
var ex = await Assert.ThrowsAsync<SpeechToTextException>(() => stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", CancellationToken.None));
Assert.Equal("OpenAI transcription failed: the response is not JSON.", ex.Message);
}
[Fact]
public async Task A_missing_text_has_its_own_message()
{
var (stt, _) = Create(HttpStatusCode.OK, "{\"segments\":[]}");
var ex = await Assert.ThrowsAsync<SpeechToTextException>(() => stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", CancellationToken.None));
Assert.Equal("OpenAI transcription failed: the response has no text.", ex.Message);
}
private sealed class HangingHandler : HttpMessageHandler
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
await Task.Delay(Timeout.Infinite, cancellationToken);
throw new InvalidOperationException("unreachable");
}
}
[Fact]
public async Task A_client_timeout_is_a_speech_failure_not_a_cancellation()
{
var http = new HttpClient(new HangingHandler()) { Timeout = TimeSpan.FromMilliseconds(50) };
var stt = new OpenAiSpeechToText(http, new SpeechOptions { ApiKey = Key });
var ex = await Assert.ThrowsAsync<SpeechToTextException>(() => stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", CancellationToken.None));
Assert.Equal("OpenAI transcription failed: the request timed out.", ex.Message);
}
[Fact]
public async Task The_callers_cancellation_still_cancels()
{
var stt = new OpenAiSpeechToText(new HttpClient(new HangingHandler()), new SpeechOptions { ApiKey = Key });
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", cts.Token));
}
}