forked from admin/VolcanicAgenticCore
ChatRegistry -> JobScheduler -> IScriptEnvironment -> ChatRegistry made the host fail at startup. CalendarScriptEnvironment now takes TelegramOptions and computes the workspace with the new static ChatRegistry.WorkspaceFor(options, chatId).
186 lines
9.4 KiB
C#
186 lines
9.4 KiB
C#
using System.Net;
|
|
using System.Text;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Time.Testing;
|
|
using Telegram.Bot;
|
|
using Telegram.Bot.Requests;
|
|
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
|
using Volcanic.AgenticCore.Features.Processes;
|
|
using Volcanic.AgenticCore.Telegram.Chats;
|
|
using Volcanic.AgenticCore.Telegram.Rendering;
|
|
using Volcanic.AgenticCore.Telegram.Tests.Support;
|
|
|
|
namespace Volcanic.AgenticCore.Telegram.Tests;
|
|
|
|
public class GoogleCalendarHostTests : IDisposable
|
|
{
|
|
private readonly string _tokens = Directory.CreateTempSubdirectory("gcal-telegram-").FullName;
|
|
private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 9, 26, 8, 0, 0, TimeSpan.Zero));
|
|
|
|
public void Dispose()
|
|
{
|
|
try { Directory.Delete(_tokens, recursive: true); } catch (IOException) { }
|
|
}
|
|
|
|
/// <summary>Google that approves on the second poll.</summary>
|
|
private sealed class ScriptedGoogle : HttpMessageHandler
|
|
{
|
|
private int _polls;
|
|
|
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
|
|
Task.FromResult(request.RequestUri!.AbsolutePath switch
|
|
{
|
|
"/device/code" => Json(HttpStatusCode.OK, """{"device_code":"dev-1","user_code":"ABCD-EFGH","verification_url":"https://www.google.com/device","expires_in":1800,"interval":5}"""),
|
|
"/token" => Interlocked.Increment(ref _polls) < 2
|
|
? Json(HttpStatusCode.PreconditionRequired, """{"error":"authorization_pending"}""")
|
|
: Json(HttpStatusCode.OK, """{"access_token":"at-1","expires_in":3599,"refresh_token":"rt-1","scope":"https://www.googleapis.com/auth/calendar"}"""),
|
|
"/calendar/v3/calendars/primary" => Json(HttpStatusCode.OK, """{"id":"owner@example.com","timeZone":"Europe/Berlin"}"""),
|
|
_ => Json(HttpStatusCode.NotImplemented, "{}"),
|
|
});
|
|
|
|
private static HttpResponseMessage Json(HttpStatusCode status, string body) =>
|
|
new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
|
|
}
|
|
|
|
private GoogleCalendarOptions Calendar() => new()
|
|
{
|
|
ClientId = "cid.apps.googleusercontent.com", ClientSecret = "GOCSPX-test-secret", TokenDirectory = _tokens,
|
|
Http = new HttpClient(new ScriptedGoogle()), Time = _time, Connections = new GoogleCalendarConnections(), GetEnv = _ => null,
|
|
};
|
|
|
|
[Fact]
|
|
public void Options_come_from_the_section_then_the_environment_and_treat_empty_as_unset()
|
|
{
|
|
var env = new Dictionary<string, string> { ["GOOGLE_CLIENT_ID"] = "env-id", ["GOOGLE_CLIENT_SECRET"] = "env-secret" };
|
|
var fromEnv = GoogleCalendarSetup.Options(new GoogleCalendarSection { ClientId = "", ClientSecret = " " }, n => env.GetValueOrDefault(n));
|
|
Assert.Equal(("env-id", "env-secret"), (fromEnv!.ClientId, fromEnv.ClientSecret));
|
|
var fromSection = GoogleCalendarSetup.Options(new GoogleCalendarSection { ClientId = "s-id", ClientSecret = "s-secret" }, n => env.GetValueOrDefault(n));
|
|
Assert.Equal(("s-id", "s-secret"), (fromSection!.ClientId, fromSection.ClientSecret));
|
|
}
|
|
|
|
[Fact]
|
|
public void Options_are_null_when_neither_value_is_set()
|
|
{
|
|
Assert.Null(GoogleCalendarSetup.Options(new GoogleCalendarSection(), _ => ""));
|
|
var half = GoogleCalendarSetup.Options(new GoogleCalendarSection { ClientId = "id" }, _ => null);
|
|
Assert.NotNull(half); // the extension logs the warning
|
|
Assert.False(GoogleCalendarSetup.IsActive(half));
|
|
}
|
|
|
|
[Fact]
|
|
public void Configure_adds_the_client_secret_to_the_secret_values()
|
|
{
|
|
var target = new ProcessEnvironmentOptions { HardenHostProcess = false };
|
|
TelegramProcessEnvironment.Configure(target, new TelegramOptions { BotToken = "123456:bot-token-value" }, new ProcessEnvironmentSection(), _ => null, Calendar());
|
|
Assert.Contains("GOCSPX-test-secret", target.SecretValues);
|
|
}
|
|
|
|
[Fact]
|
|
public void The_menu_gets_the_calendar_commands_only_when_both_values_are_set()
|
|
{
|
|
Assert.Same(ChatCommands.Menu, GoogleCalendarSetup.MenuFor(ChatCommands.Menu, null));
|
|
Assert.Same(ChatCommands.Menu, GoogleCalendarSetup.MenuFor(ChatCommands.Menu, new GoogleCalendarOptions { ClientId = "id", GetEnv = _ => null }));
|
|
var menu = GoogleCalendarSetup.MenuFor(ChatCommands.Menu, Calendar());
|
|
Assert.Equal(["calendar_connect", "calendar_status", "calendar_disconnect"], menu.Skip(ChatCommands.Menu.Count).Select(c => c.Command).ToArray());
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(true)]
|
|
[InlineData(false)]
|
|
public async Task Bot_setup_publishes_the_calendar_commands_only_when_the_calendar_is_configured(bool configured)
|
|
{
|
|
var bot = new FakeBotClient();
|
|
var services = new ServiceCollection()
|
|
.AddSingleton<ITelegramBotClient>(bot)
|
|
.AddSingleton(new TelegramOptions())
|
|
.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>))
|
|
.AddSingleton<TelegramSender>()
|
|
.AddSingleton<BotSetupService>();
|
|
if (configured) services.AddSingleton(Calendar()); // as Program.cs registers it
|
|
using var provider = services.BuildServiceProvider();
|
|
var setup = provider.GetRequiredService<BotSetupService>();
|
|
|
|
await setup.StartAsync(CancellationToken.None);
|
|
var deadline = DateTime.UtcNow.AddSeconds(10);
|
|
while (!bot.Requests.OfType<SetMyCommandsRequest>().Any() && DateTime.UtcNow < deadline) await Task.Delay(10);
|
|
await setup.StopAsync(CancellationToken.None);
|
|
|
|
var commands = Assert.Single(bot.Requests.OfType<SetMyCommandsRequest>()).Commands.Select(c => c.Command).ToList();
|
|
var calendarCommands = new[] { "calendar_connect", "calendar_status", "calendar_disconnect" };
|
|
if (configured) Assert.Equal(calendarCommands, commands.Skip(ChatCommands.Menu.Count).ToArray());
|
|
else Assert.DoesNotContain(commands, c => calendarCommands.Contains(c));
|
|
Assert.Equal(ChatCommands.Menu.Select(c => c.Command), commands.Take(ChatCommands.Menu.Count));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Every_allowed_chat_lists_the_calendar_tool()
|
|
{
|
|
await using var host = new ChatHost(calendar: Calendar());
|
|
host.Options.AllowedUserIds.Add(222222222);
|
|
Assert.Contains("calendar", (await host.ChatAsync()).Session.ActiveTools);
|
|
Assert.Contains("calendar", (await host.ChatAsync(222222222)).Session.ActiveTools);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Without_calendar_options_no_chat_has_the_tool()
|
|
{
|
|
await using var host = new ChatHost();
|
|
Assert.DoesNotContain("calendar", (await host.ChatAsync()).Session.ActiveTools);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Help_mentions_the_calendar_commands_when_the_extension_is_active()
|
|
{
|
|
await using var host = new ChatHost(calendar: Calendar());
|
|
await host.SendAsync("/help");
|
|
await Wait.UntilAsync(() => host.Texts.Any(t => t.Contains("/calendar_connect")), "the help text");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Help_leaves_out_the_calendar_commands_without_the_extension()
|
|
{
|
|
await using var host = new ChatHost();
|
|
await host.SendAsync("/help");
|
|
await Wait.UntilAsync(() => host.Texts.Any(t => t.Contains("/compact")), "the help text");
|
|
Assert.DoesNotContain(host.Texts, t => t.Contains("/calendar_connect"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Calendar_connect_in_a_chat_ends_with_the_connected_message()
|
|
{
|
|
await using var host = new ChatHost(calendar: Calendar());
|
|
await host.SendAsync("/calendar_connect");
|
|
await Wait.UntilAsync(() => host.Texts.Any(t => t.Contains("ABCD-EFGH")), "the link and code");
|
|
await Wait.UntilAsync(() =>
|
|
{
|
|
_time.Advance(TimeSpan.FromSeconds(5));
|
|
return host.Texts.Any(t => t.Contains("Google Calendar connected as owner@example.com"));
|
|
}, "the connected message");
|
|
Assert.True(File.Exists(Path.Combine(_tokens, ChatRegistry.ScopeFor(Updates.AllowedUser), "token.json")));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Watchers_get_a_short_lived_access_token_and_the_token_file_stays_sealed()
|
|
{
|
|
var calendar = Calendar();
|
|
await using var host = new ChatHost(calendar: calendar);
|
|
var environment = new Volcanic.AgenticCore.Telegram.Scheduling.CalendarScriptEnvironment(host.Options, calendar);
|
|
var target = Updates.AllowedUser.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
|
Assert.Empty(await environment.ForRunAsync(target, TimeSpan.FromMinutes(5), CancellationToken.None)); // not connected yet
|
|
|
|
await host.SendAsync("/calendar_connect");
|
|
await Wait.UntilAsync(() =>
|
|
{
|
|
_time.Advance(TimeSpan.FromSeconds(5));
|
|
return host.Texts.Any(t => t.Contains("Google Calendar connected as owner@example.com"));
|
|
}, "the connected message");
|
|
|
|
var variables = await environment.ForRunAsync(target, TimeSpan.FromMinutes(5), CancellationToken.None);
|
|
Assert.Equal("at-1", Assert.Single(variables, v => v.Key == "GOOGLE_ACCESS_TOKEN").Value);
|
|
Assert.DoesNotContain("rt-1", File.ReadAllText(Path.Combine(_tokens, ChatRegistry.ScopeFor(Updates.AllowedUser), "token.json")));
|
|
Assert.Empty(await environment.ForRunAsync("not-a-chat", TimeSpan.FromMinutes(5), CancellationToken.None));
|
|
}
|
|
}
|