Files
Playa/tests/Volcanic.AgenticCore.Telegram.Tests/GoogleCalendarHostTests.cs
EugeneTes 881e7f0be9 fix(google-calendar): address review findings
- log an unknown stored zone once even when the tool description reads it first
- serialise the flow's token write with /calendar_disconnect under the flow gate
- a cancelled or expired pending flow is cleared, not re-sent or shown
- chat keys anchor with \z so a trailing newline is hashed
- a timeMax date includes that whole day (spec updated)
- dispose the flow's CancellationTokenSource, drop ConnectFlow.Token, Begin is a plain write
- GoogleCalendarOptions.IsConfigured replaces the host's copy of the rule; StoredToken is internal
- test BotSetupService's menu through DI, tokens absent from tool results
- Telegram.Tests csproj reference placed as in telegram-message-context; ChatHost passes calendar by name
- docs: per-event timeZone, both config sources for /calendar_*
2026-09-26 20:30:56 +02:00

162 lines
8.0 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.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<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")));
}
}