Files
Playa/tests/Volcanic.AgenticCore.Telegram.Tests/JevClientTests.cs

144 lines
7.0 KiB
C#

using System.Net;
using System.Text;
using System.Text.Json.Nodes;
using Volcanic.AgenticCore.Features.Processes;
using Volcanic.AgenticCore.Telegram.Triggers;
namespace Volcanic.AgenticCore.Telegram.Tests;
public sealed class JevClientTests
{
private const string Key = "sk-or-test-secret-123";
private sealed class ScriptedHandler(params Func<HttpResponseMessage>[] responses) : HttpMessageHandler
{
public List<(HttpRequestMessage Request, string Body)> Calls { get; } = [];
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Calls.Add((request, await request.Content!.ReadAsStringAsync(cancellationToken)));
return responses[Math.Min(Calls.Count - 1, responses.Length - 1)]();
}
}
private static HttpResponseMessage Json(HttpStatusCode status, string body) => new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
private static Func<HttpResponseMessage> Ok(string answers) => () => Json(HttpStatusCode.OK, $"{{\"model\":\"typesafe/jev-1.13-20260917\",\"answers\":{answers}}}");
private static (JevClient Client, ScriptedHandler Handler) Create(params Func<HttpResponseMessage>[] responses)
{
var handler = new ScriptedHandler(responses);
var options = new TriggerOptions { ApiKey = Key, BaseUrl = "https://or.example/api/v1/", RetryDelay = TimeSpan.Zero };
return (new JevClient(new HttpClient(handler), options), handler);
}
private static readonly JevQuestion Deploys = new("q0", "Is this about deploys?", "Mentions deploys", "Small talk");
private static readonly JevQuestion Lunch = new("q1", "Is this about lunch?");
[Fact]
public async Task Posts_state_model_and_noul_questions_and_reads_the_answers()
{
var (client, handler) = Create(Ok("{\"q0\":{\"type\":\"noul\",\"noul\":0.93},\"q1\":{\"type\":\"noul\",\"noul\":0.01}}"));
var answers = await client.AskAsync(new JsonObject { ["text"] = "prod is down" }, [Deploys, Lunch], CancellationToken.None);
Assert.Equal(0.93, answers["q0"]);
Assert.Equal(0.01, answers["q1"]);
var (request, body) = Assert.Single(handler.Calls);
Assert.Equal("https://or.example/api/v1/systemone", request.RequestUri!.ToString());
Assert.Equal(("Bearer", Key), (request.Headers.Authorization!.Scheme, request.Headers.Authorization.Parameter));
var json = JsonNode.Parse(body)!;
Assert.Equal("prod is down", (string?)json["state"]!["text"]);
Assert.Equal(TriggerOptions.DefaultModel, (string?)json["model"]);
Assert.Equal("noul", (string?)json["questions"]!["q0"]!["type"]);
Assert.Equal("Mentions deploys", (string?)json["questions"]!["q0"]!["criteria"]!["true"]);
Assert.Equal("Small talk", (string?)json["questions"]!["q0"]!["criteria"]!["false"]);
Assert.Null(json["questions"]!["q1"]!["criteria"]); // only when both sides are set
}
[Fact]
public async Task No_questions_makes_no_call()
{
var (client, handler) = Create(Ok("{}"));
Assert.Empty(await client.AskAsync(JsonValue.Create("x"), [], CancellationToken.None));
Assert.Empty(handler.Calls);
}
[Fact]
public async Task Transient_failures_are_retried_three_times_then_fail_with_four_attempts()
{
var (client, handler) = Create(() => Json(HttpStatusCode.ServiceUnavailable, "{\"error\":\"busy\"}"));
var ex = await Assert.ThrowsAsync<JevException>(() => client.AskAsync(JsonValue.Create("x"), [Lunch], CancellationToken.None));
Assert.Equal(4, handler.Calls.Count);
Assert.Equal(4, ex.Attempts);
Assert.Contains("HTTP 503", ex.Message, StringComparison.Ordinal);
}
[Fact]
public async Task A_retry_that_succeeds_returns_the_answers()
{
var (client, handler) = Create(
() => throw new HttpRequestException("connection reset"),
() => { var r = Json(HttpStatusCode.TooManyRequests, "slow down"); r.Headers.RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(TimeSpan.Zero); return r; },
Ok("{\"q1\":{\"type\":\"noul\",\"noul\":0.5}}"));
var answers = await client.AskAsync(JsonValue.Create("x"), [Lunch], CancellationToken.None);
Assert.Equal(0.5, answers["q1"]);
Assert.Equal(3, handler.Calls.Count);
}
[Fact]
public async Task A_client_error_is_not_retried_and_never_shows_the_key()
{
var (client, handler) = Create(() => Json(HttpStatusCode.BadRequest, $"{{\"error\":\"bad key {Key}\"}}"));
var ex = await Assert.ThrowsAsync<JevException>(() => client.AskAsync(JsonValue.Create("x"), [Lunch], CancellationToken.None));
Assert.Single(handler.Calls);
Assert.Equal(1, ex.Attempts);
Assert.DoesNotContain(Key, ex.Message, StringComparison.Ordinal);
Assert.Contains("[REDACTED]", ex.Message, StringComparison.Ordinal);
}
[Fact]
public async Task A_missing_answer_is_a_failure()
{
var (client, _) = Create(Ok("{\"q0\":{\"type\":\"noul\",\"noul\":0.9}}"));
var ex = await Assert.ThrowsAsync<JevException>(() => client.AskAsync(JsonValue.Create("x"), [Deploys, Lunch], CancellationToken.None));
Assert.Contains("q1", ex.Message, StringComparison.Ordinal);
}
[Fact]
public void Options_read_the_environment_only_when_empty_and_validate()
{
var env = new Dictionary<string, string> { ["OPENROUTER_API_KEY"] = " key ", ["TRIGGER_MODEL"] = "typesafe/jev-2" };
var options = new TelegramOptions { BotToken = "t", AllowedUserIds = [1] };
options.ApplyEnvironment(name => env.GetValueOrDefault(name));
Assert.True(options.Triggers.IsEnabled);
Assert.Equal("key", options.Triggers.ApiKey);
Assert.Equal("typesafe/jev-2", options.Triggers.ResolvedModel);
var configured = new TelegramOptions { Triggers = new TriggerOptions { ApiKey = "mine" } };
configured.ApplyEnvironment(name => env.GetValueOrDefault(name));
Assert.Equal("mine", configured.Triggers.ApiKey);
var bad = new TelegramOptions { BotToken = "t", AllowedUserIds = [1], Triggers = new TriggerOptions { BaseUrl = "/v1", DefaultThreshold = 1.5, MaxConcurrency = 0 } };
var errors = bad.Validate();
Assert.Contains(errors, e => e.Contains("Triggers:BaseUrl", StringComparison.Ordinal));
Assert.Contains(errors, e => e.Contains("DefaultThreshold", StringComparison.Ordinal));
Assert.Contains(errors, e => e.Contains("MaxConcurrency", StringComparison.Ordinal));
Assert.Empty(new TelegramOptions { BotToken = "t", AllowedUserIds = [1] }.Validate());
}
[Fact]
public void The_key_is_a_secret_value_for_agent_processes()
{
var target = new ProcessEnvironmentOptions();
TelegramProcessEnvironment.Configure(target, new TelegramOptions { Triggers = new TriggerOptions { ApiKey = Key } }, new ProcessEnvironmentSection(), _ => null);
Assert.Contains(Key, target.SecretValues);
}
}