Merge branch 'feat/google-calendar'

This commit is contained in:
EugeneTes
2026-09-26 21:24:34 +02:00
49 changed files with 8405 additions and 52 deletions

View File

@@ -0,0 +1,134 @@
using System.Net;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
public class CalendarCommandsTests
{
private const string Status = "calendar_status";
private const string Disconnect = "calendar_disconnect";
[Fact]
public async Task Status_when_not_connected()
{
using var h = new ConnectHarness();
var ui = new CalendarRecordingUi();
await h.Run(Status, h.Context(ui));
Assert.Equal(("Not connected. Run /calendar_connect.", NotifyKind.Info), Assert.Single(ui.Notes));
}
[Fact]
public async Task Status_when_connected_shows_account_date_and_zone()
{
using var h = new ConnectHarness();
h.Connection().Store.Write(new StoredToken("rt-1", null, "me@example.com", "Europe/Berlin", ConnectHarness.Now));
var ui = new CalendarRecordingUi();
await h.Run(Status, h.Context(ui));
Assert.Equal("Connected as me@example.com since 2026-09-26, time zone Europe/Berlin.", Assert.Single(ui.Notes).Message);
}
[Fact]
public async Task Status_while_waiting_shows_the_link_code_and_minutes_left()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending);
var connect = h.Run("calendar_connect", h.Context(new CalendarRecordingUi()));
await FlowDriver.ParkedAsync(h.Connection());
var ui = new CalendarRecordingUi();
await h.Run(Status, h.Context(ui));
Assert.Equal("Waiting for approval: open https://www.google.com/device and enter ABCD-EFGH (30 min left).", Assert.Single(ui.Notes).Message);
h.Connection().CancelPending();
await connect.WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task Disconnect_cancels_a_pending_flow_silently()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending);
var ui = new CalendarRecordingUi();
var connect = h.Run("calendar_connect", h.Context(ui));
await FlowDriver.ParkedAsync(h.Connection());
await h.Run(Disconnect, h.Context(ui));
await connect.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(2, ui.Notes.Count); // the link, then the disconnect reply; no flow message
Assert.Equal("Calendar disconnected.", ui.Notes[1].Message);
Assert.Null(h.Connection().Pending);
Assert.Empty(h.Http.To("POST /revoke")); // nothing stored, nothing to revoke
}
[Fact]
public async Task Disconnect_while_the_flow_completes_leaves_the_chat_disconnected()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Approved());
var primaryEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var primaryRelease = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
h.Http.On(ConnectHarness.PrimaryRoute, async _ =>
{
primaryEntered.TrySetResult();
await primaryRelease.Task; // ignores cancellation: the lookup returns after the disconnect
return GoogleFakeHttp.Response(HttpStatusCode.OK, """{"id":"me@example.com","timeZone":"Europe/Berlin"}""");
});
var ui = new CalendarRecordingUi();
var connect = h.Run("calendar_connect", h.Context(ui));
await FlowDriver.ParkedAsync(h.Connection());
h.Time.Advance(TimeSpan.FromSeconds(5));
await primaryEntered.Task.WaitAsync(TimeSpan.FromSeconds(5));
await h.Run(Disconnect, h.Context(ui));
primaryRelease.SetResult();
await connect.WaitAsync(TimeSpan.FromSeconds(5));
Assert.False(File.Exists(h.Connection().Store.Path));
Assert.Equal(["Calendar disconnected."], ui.Notes.Skip(1).Select(n => n.Message).ToArray()); // no "connected"
Assert.Null(h.Connection().Pending);
}
[Fact]
public async Task Disconnect_revokes_deletes_and_invalidates()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, """{"access_token":"at-1","expires_in":3600}""");
h.Http.Json("POST /revoke", "{}");
var connection = h.Connection();
connection.Store.Write(new StoredToken("rt-1"));
await connection.GetAccessTokenAsync(CancellationToken.None); // cache primed
var ui = new CalendarRecordingUi();
await h.Run(Disconnect, h.Context(ui));
Assert.Equal("rt-1", Assert.Single(h.Http.To("POST /revoke")).Form["token"]);
Assert.False(File.Exists(connection.Store.Path));
Assert.Equal("Calendar disconnected.", Assert.Single(ui.Notes).Message);
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => connection.GetAccessTokenAsync(CancellationToken.None));
Assert.True(ex.NotConnected);
}
[Fact]
public async Task Disconnect_with_a_revoke_failure_still_deletes_and_links_the_permissions_page()
{
using var h = new ConnectHarness();
h.Http.Json("POST /revoke", (HttpStatusCode.BadRequest, """{"error":"invalid_token"}"""));
h.Connection().Store.Write(new StoredToken("rt-1"));
var ui = new CalendarRecordingUi();
await h.Run(Disconnect, h.Context(ui));
Assert.False(File.Exists(h.Connection().Store.Path));
Assert.Equal("Calendar disconnected. Revoking at Google failed: invalid_token; remove the app at https://myaccount.google.com/permissions.",
Assert.Single(ui.Notes).Message);
}
[Fact]
public async Task Without_a_ui_status_is_a_custom_message_without_a_turn()
{
using var h = new ConnectHarness();
await h.Run(Status, h.Context(ui: null));
var (message, _, triggerTurn) = Assert.Single(h.Api.Sent);
Assert.Equal("calendar_status", message.CustomType);
Assert.Equal("Not connected. Run /calendar_connect.", message.Text);
Assert.False(triggerTurn);
}
}

View File

@@ -0,0 +1,134 @@
using System.Text.Json;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
public class CalendarTimeTests
{
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
private static readonly TimeZoneInfo NewYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
private static JsonElement El(string json) => JsonDocument.Parse(json).RootElement.Clone();
[Fact]
public void A_date_is_an_all_day_value()
{
var value = CalendarTime.Parse("2026-09-30", "start");
Assert.Equal(CalendarTimeKind.Date, value.Kind);
Assert.Equal("""{"date":"2026-09-30"}""", CalendarTime.ToEventTime(value, "Europe/Berlin").ToJsonString());
}
[Theory]
[InlineData("2026-09-30T10:00:00+02:00")]
[InlineData("2026-09-30T08:00:00Z")]
public void An_offset_date_time_is_sent_as_is_with_the_zone(string text)
{
var json = CalendarTime.ToEventTime(CalendarTime.Parse(text, "start"), "Europe/Berlin");
Assert.Equal(text, json["dateTime"]!.GetValue<string>());
Assert.Equal("Europe/Berlin", json["timeZone"]!.GetValue<string>());
}
[Fact]
public void An_offset_date_time_without_seconds_is_sent_as_full_rfc3339()
{
var json = CalendarTime.ToEventTime(CalendarTime.Parse("2026-09-30T10:00+02:00", "start"), "Europe/Berlin");
Assert.Equal("2026-09-30T10:00:00+02:00", json["dateTime"]!.GetValue<string>());
}
[Theory]
[InlineData("2026-09-30T10:00")]
[InlineData("2026-09-30T10:00:00")]
[InlineData("2026-09-30 10:00")]
public void A_local_date_time_is_sent_without_offset_with_the_zone(string text)
{
var json = CalendarTime.ToEventTime(CalendarTime.Parse(text, "start"), "Europe/Berlin");
Assert.Equal("""{"dateTime":"2026-09-30T10:00:00","timeZone":"Europe/Berlin"}""", json.ToJsonString());
}
[Fact]
public void Create_all_day_defaults_end_to_the_next_day()
{
var (start, end) = CalendarTime.ForCreate("2026-09-30", null, "Europe/Berlin");
Assert.Equal("""{"date":"2026-09-30"}""", start.ToJsonString());
Assert.Equal("""{"date":"2026-10-01"}""", end.ToJsonString());
}
[Fact]
public void Create_timed_defaults_end_to_one_hour_later()
{
var (_, local) = CalendarTime.ForCreate("2026-09-30T10:00", null, "Europe/Berlin");
Assert.Equal("2026-09-30T11:00:00", local["dateTime"]!.GetValue<string>());
var (_, offset) = CalendarTime.ForCreate("2026-09-30T10:00:00+02:00", null, "Europe/Berlin");
Assert.Equal("2026-09-30T11:00:00+02:00", offset["dateTime"]!.GetValue<string>());
}
[Theory]
[InlineData("2026-09-30", "2026-09-30T11:00")]
[InlineData("2026-09-30T10:00", "2026-10-01")]
public void Mixed_date_and_date_time_is_rejected(string start, string end)
{
var ex = Assert.Throws<ArgumentException>(() => CalendarTime.ForCreate(start, end, "Europe/Berlin"));
Assert.Equal("start and end must both be dates (YYYY-MM-DD) or both date-times.", ex.Message);
Assert.Throws<ArgumentException>(() => CalendarTime.ForUpdate(start, end, "Europe/Berlin"));
}
[Fact]
public void Update_sends_only_the_given_side()
{
var (start, end) = CalendarTime.ForUpdate("2026-09-30T09:00", null, "Europe/Berlin");
Assert.NotNull(start);
Assert.Null(end);
}
[Fact]
public void An_unparsable_time_is_rejected_with_the_field_name()
{
var ex = Assert.Throws<ArgumentException>(() => CalendarTime.Parse("next tuesday", "timeMin"));
Assert.StartsWith("timeMin must be YYYY-MM-DD", ex.Message);
Assert.Contains("next tuesday", ex.Message);
}
[Theory]
[InlineData("2026-09-30", "2026-09-30T00:00:00+02:00")]
[InlineData("2026-12-01T10:00", "2026-12-01T10:00:00+01:00")]
[InlineData("2026-09-30T10:00:00-04:00", "2026-09-30T10:00:00-04:00")]
[InlineData("2026-09-30T08:00:00Z", "2026-09-30T08:00:00+00:00")]
public void TimeMin_is_rfc3339_with_an_offset_in_the_zone(string text, string expected) =>
Assert.Equal(expected, CalendarTime.ToRfc3339(text, Berlin, "timeMin"));
[Theory]
[InlineData("2026-09-30", "2026-10-01T00:00:00+02:00")] // a date is inclusive: its whole day is in the window
[InlineData("2026-10-25", "2026-10-26T00:00:00+01:00")] // the next midnight's offset: Berlin leaves DST on the 25th
[InlineData("2026-09-30T18:00", "2026-09-30T18:00:00+02:00")]
[InlineData("2026-09-30T18:00:00Z", "2026-09-30T18:00:00+00:00")]
public void TimeMax_as_a_date_ends_at_the_next_midnight_in_the_zone(string text, string expected) =>
Assert.Equal(expected, CalendarTime.ToRfc3339(text, Berlin, "timeMax", dateIsInclusiveEnd: true));
[Fact]
public void Utc_formats_with_Z() =>
Assert.Equal("2026-09-26T08:00:00Z", CalendarTime.Utc(new DateTimeOffset(2026, 9, 26, 10, 0, 0, TimeSpan.FromHours(2))));
[Fact]
public void Formats_a_timed_range_in_the_zone()
{
var start = El("""{"dateTime":"2026-09-30T10:00:00+02:00","timeZone":"Europe/Berlin"}""");
var end = El("""{"dateTime":"2026-09-30T11:00:00+02:00"}""");
Assert.Equal("Wed 30 Sep 10:00–11:00", CalendarTime.FormatRange(start, end, Berlin));
Assert.Equal("Wed 30 Sep 04:00–05:00", CalendarTime.FormatRange(start, end, NewYork));
Assert.Equal("Wed 30 Sep 23:00 – Thu 1 Oct 01:00",
CalendarTime.FormatRange(El("""{"dateTime":"2026-09-30T23:00:00+02:00"}"""), El("""{"dateTime":"2026-10-01T01:00:00+02:00"}"""), Berlin));
}
[Fact]
public void Formats_an_all_day_range_with_an_exclusive_end()
{
Assert.Equal("Wed 30 Sep (all day)", CalendarTime.FormatRange(El("""{"date":"2026-09-30"}"""), El("""{"date":"2026-10-01"}"""), Berlin));
Assert.Equal("Wed 30 Sep – Fri 2 Oct (all day)", CalendarTime.FormatRange(El("""{"date":"2026-09-30"}"""), El("""{"date":"2026-10-03"}"""), Berlin));
}
[Fact]
public void Header_names_one_zone_or_both()
{
Assert.Equal("Times in Europe/Berlin.", CalendarTime.Header(Berlin, Berlin));
Assert.Equal("Times in America/New_York (the calendar's zone; this chat is in Europe/Berlin).", CalendarTime.Header(NewYork, Berlin));
}
}

View File

@@ -0,0 +1,277 @@
using System.Net;
using System.Text.Json;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
public class CalendarToolReadTests
{
private static readonly string Weekly = Ev.Timed("ev-1_20260930T080000Z", "Standup", "2026-09-30T10:00:00+02:00", "2026-09-30T10:15:00+02:00", recurringEventId: "ev-1");
private static readonly string Single = Ev.Timed("ev-2", "Dentist", "2026-10-01T14:00:00+02:00", "2026-10-01T15:00:00+02:00", location: "Main St 1");
[Fact]
public async Task List_defaults_to_the_next_seven_days_as_single_events_ordered_by_start()
{
using var h = new CalendarToolHarness();
h.Http.Json(CalendarToolHarness.EventsRoute, Ev.List(Single));
var result = await h.RunAsync(new { action = "list" });
var request = Assert.Single(h.CalendarRequests);
Assert.Equal("Bearer at-1", request.Authorization);
Assert.Equal("2026-09-26T08:00:00Z", request.Query["timeMin"]);
Assert.Equal("2026-10-03T08:00:00Z", request.Query["timeMax"]);
Assert.Equal("true", request.Query["singleEvents"]);
Assert.Equal("startTime", request.Query["orderBy"]);
Assert.Equal("25", request.Query["maxResults"]);
Assert.Equal("Times in Europe/Berlin.\nev-2 · Dentist · Thu 1 Oct 14:00–15:00 @ Main St 1", result.TextContent);
}
[Fact]
public async Task List_marks_recurring_occurrences_with_their_series()
{
using var h = new CalendarToolHarness();
h.Http.Json(CalendarToolHarness.EventsRoute, Ev.List(Weekly));
var result = await h.RunAsync(new { action = "list" });
Assert.Contains("ev-1_20260930T080000Z · Standup · Wed 30 Sep 10:00–10:15 · recurring (series=ev-1)", result.TextContent);
}
[Fact]
public async Task List_series_mode_requests_masters_and_shows_the_rrule()
{
using var h = new CalendarToolHarness();
h.Http.Json(CalendarToolHarness.EventsRoute, Ev.List(Ev.Timed("ev-1", "Standup", "2026-09-28T10:00:00+02:00", "2026-09-28T10:15:00+02:00",
recurrence: ["RRULE:FREQ=WEEKLY;BYDAY=MO,WE"])));
var result = await h.RunAsync(new { action = "list", listMode = "series" });
var query = Assert.Single(h.CalendarRequests).Query;
Assert.Equal("false", query["singleEvents"]);
Assert.False(query.ContainsKey("orderBy"));
Assert.Contains("ev-1 · Standup · Mon 28 Sep 10:00–10:15 · RRULE=FREQ=WEEKLY;BYDAY=MO,WE", result.TextContent);
}
[Fact]
public async Task List_rejects_an_unknown_list_mode()
{
using var h = new CalendarToolHarness();
var ex = await Assert.ThrowsAsync<ArgumentException>(() => h.RunAsync(new { action = "list", listMode = "everything" }));
Assert.Equal("listMode must be occurrences or series.", ex.Message);
}
[Fact]
public async Task TimeMin_given_as_a_date_is_sent_as_rfc3339_in_the_zone()
{
using var h = new CalendarToolHarness();
h.Http.Json(CalendarToolHarness.EventsRoute, Ev.List());
await h.RunAsync(new { action = "list", timeMin = "2026-09-30", timeMax = "2026-10-01T18:00" });
var query = Assert.Single(h.CalendarRequests).Query;
Assert.Equal("2026-09-30T00:00:00+02:00", query["timeMin"]);
Assert.Equal("2026-10-01T18:00:00+02:00", query["timeMax"]);
}
[Fact]
public async Task Max_results_is_capped_at_250()
{
using var h = new CalendarToolHarness();
h.Http.Json(CalendarToolHarness.EventsRoute, Ev.List());
await h.RunAsync(new { action = "list", maxResults = 1000 });
Assert.Equal("250", Assert.Single(h.CalendarRequests).Query["maxResults"]);
}
[Fact]
public async Task Search_requires_query()
{
using var h = new CalendarToolHarness();
var ex = await Assert.ThrowsAsync<ArgumentException>(() => h.RunAsync(new { action = "search" }));
Assert.Equal("search needs query.", ex.Message);
Assert.Empty(h.CalendarRequests);
}
[Fact]
public async Task Search_sends_q_and_the_default_window()
{
using var h = new CalendarToolHarness();
h.Http.Json(CalendarToolHarness.EventsRoute, Ev.List(Single));
await h.RunAsync(new { action = "search", query = "dentist" });
var query = Assert.Single(h.CalendarRequests).Query;
Assert.Equal("dentist", query["q"]);
Assert.Equal("2026-08-27T08:00:00Z", query["timeMin"]);
Assert.Equal("2027-09-26T08:00:00Z", query["timeMax"]);
}
[Fact]
public async Task Get_renders_one_event_with_its_description()
{
using var h = new CalendarToolHarness();
var withDescription = Single.TrimEnd('}') + ",\"description\":\"Bring the card\"}";
h.Http.Json("GET /calendar/v3/calendars/primary/events/ev-2", withDescription);
var result = await h.RunAsync(new { action = "get", eventId = "ev-2" });
Assert.Equal("Times in Europe/Berlin.\nev-2 · Dentist · Thu 1 Oct 14:00–15:00 @ Main St 1\nDescription: Bring the card", result.TextContent);
}
[Fact]
public async Task List_instances_follows_next_page_tokens_up_to_max_results()
{
using var h = new CalendarToolHarness();
string Occ(int day) => Ev.Timed($"ev-1_202610{day:00}", "Standup", $"2026-10-{day:00}T10:00:00+02:00", $"2026-10-{day:00}T10:15:00+02:00", recurringEventId: "ev-1");
h.Http.Json("GET /calendar/v3/calendars/primary/events/ev-1/instances",
(HttpStatusCode.OK, Ev.Page("p2", Occ(5), Occ(7), Occ(12))),
(HttpStatusCode.OK, Ev.Page("p3", Occ(14), Occ(19))));
var result = await h.RunAsync(new { action = "list_instances", eventId = "ev-1", maxResults = 4 });
var requests = h.CalendarRequests;
Assert.Equal(2, requests.Count);
Assert.False(requests[0].Query.ContainsKey("pageToken"));
Assert.Equal("p2", requests[1].Query["pageToken"]);
Assert.Equal(5, result.TextContent.Split('\n').Length); // header + 4 occurrences
Assert.DoesNotContain("ev-1_20261019", result.TextContent);
}
[Fact]
public async Task Calendars_lists_entries_with_primary_marked()
{
using var h = new CalendarToolHarness();
h.Http.Json("GET /calendar/v3/users/me/calendarList", """
{"items":[{"id":"me@example.com","summary":"Me","accessRole":"owner","timeZone":"Europe/Berlin","primary":true},
{"id":"family@group.calendar.google.com","summary":"Family","accessRole":"writer","timeZone":"Europe/Berlin"}]}
""");
var result = await h.RunAsync(new { action = "calendars" });
Assert.Contains("me@example.com · Me · owner · Europe/Berlin · primary", result.TextContent);
Assert.Contains("family@group.calendar.google.com · Family · writer · Europe/Berlin", result.TextContent);
}
[Fact]
public async Task The_header_names_both_zones_when_the_calendar_and_the_chat_differ()
{
using var h = new CalendarToolHarness(calendarZone: "America/New_York");
h.Http.Json(CalendarToolHarness.EventsRoute, Ev.List(Weekly));
var result = await h.RunAsync(new { action = "list" });
Assert.StartsWith("Times in America/New_York (the calendar's zone; this chat is in Europe/Berlin).\n", result.TextContent);
Assert.Contains("Wed 30 Sep 04:00–04:15", result.TextContent);
}
[Fact]
public async Task Without_a_stored_zone_the_chat_zone_is_used()
{
using var h = new CalendarToolHarness(calendarZone: null) { ChatZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo") };
h.Http.Json(CalendarToolHarness.EventsRoute, Ev.List(Weekly));
var result = await h.RunAsync(new { action = "list" });
Assert.StartsWith("Times in Asia/Tokyo.\n", result.TextContent);
Assert.Contains("Wed 30 Sep 17:00–17:15", result.TextContent);
}
[Fact]
public async Task Details_carry_the_raw_events()
{
using var h = new CalendarToolHarness();
h.Http.Json(CalendarToolHarness.EventsRoute, Ev.List(Weekly));
var result = await h.RunAsync(new { action = "list" });
var details = result.Details!.Value;
Assert.Equal("list", details.GetProperty("action").GetString());
var ev = Assert.Single(details.GetProperty("events").EnumerateArray().ToList());
Assert.Equal("ev-1", ev.GetProperty("recurringEventId").GetString());
Assert.Equal("2026-09-30T10:00:00+02:00", ev.GetProperty("start").GetProperty("dateTime").GetString());
Assert.StartsWith("https://calendar.google.com/", ev.GetProperty("htmlLink").GetString());
foreach (var secret in new[] { "rt-1", "at-1" }) // the refresh and access tokens never reach the model
{
Assert.DoesNotContain(secret, result.TextContent);
Assert.DoesNotContain(secret, details.GetRawText());
}
}
[Fact]
public async Task TimeMax_given_as_the_same_date_as_timeMin_covers_that_whole_day()
{
using var h = new CalendarToolHarness();
h.Http.Json(CalendarToolHarness.EventsRoute, Ev.List());
await h.RunAsync(new { action = "list", timeMin = "2026-09-30", timeMax = "2026-09-30" });
var query = Assert.Single(h.CalendarRequests).Query;
Assert.Equal("2026-09-30T00:00:00+02:00", query["timeMin"]);
Assert.Equal("2026-10-01T00:00:00+02:00", query["timeMax"]);
}
[Fact]
public async Task Without_a_connection_the_tool_tells_the_model_to_ask_for_calendar_connect()
{
using var h = new CalendarToolHarness(connected: false);
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => h.RunAsync(new { action = "list" }));
Assert.True(ex.NotConnected);
Assert.Contains("/calendar_connect in this chat", ex.Message);
Assert.Empty(h.Http.Requests);
}
[Fact]
public async Task Invalid_grant_tells_the_model_to_ask_for_a_reconnect()
{
using var h = new CalendarToolHarness();
h.Http.Json("POST /token", (HttpStatusCode.BadRequest, """{"error":"invalid_grant"}"""));
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => h.RunAsync(new { action = "list" }));
Assert.Equal("Google rejected the stored calendar token (revoked or expired). Ask the user to run /calendar_connect in this chat.", ex.Message);
}
[Fact]
public async Task A_401_from_the_events_endpoint_refreshes_once_and_retries()
{
using var h = new CalendarToolHarness();
h.Http.Json("POST /token", (HttpStatusCode.OK, """{"access_token":"at-1","expires_in":3600}"""), (HttpStatusCode.OK, """{"access_token":"at-2","expires_in":3600}"""));
h.Http.Json(CalendarToolHarness.EventsRoute,
(HttpStatusCode.Unauthorized, """{"error":{"code":401,"message":"Invalid Credentials"}}"""),
(HttpStatusCode.OK, Ev.List(Single)));
var result = await h.RunAsync(new { action = "list" });
Assert.Equal(2, h.Http.To("POST /token").Count);
Assert.Equal("Bearer at-2", h.CalendarRequests[1].Authorization);
Assert.Contains("Dentist", result.TextContent);
}
[Fact]
public async Task A_second_401_is_reported_not_retried_again()
{
using var h = new CalendarToolHarness();
h.Http.Json(CalendarToolHarness.EventsRoute, (HttpStatusCode.Unauthorized, """{"error":{"code":401,"message":"Invalid Credentials"}}"""));
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => h.RunAsync(new { action = "list" }));
Assert.Equal("Google Calendar list failed: 401 Invalid Credentials", ex.Message);
Assert.Equal(2, h.CalendarRequests.Count);
}
[Theory]
[InlineData(HttpStatusCode.NotFound)]
[InlineData(HttpStatusCode.Gone)]
public async Task A_missing_event_names_the_event_and_calendar(HttpStatusCode status)
{
using var h = new CalendarToolHarness();
h.Http.Json("GET /calendar/v3/calendars/primary/events/nope", (status, """{"error":{"code":404,"message":"Not Found"}}"""));
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => h.RunAsync(new { action = "get", eventId = "nope" }));
Assert.Equal("Event nope not found in primary.", ex.Message);
}
[Fact]
public async Task Other_errors_carry_the_status_and_googles_message()
{
using var h = new CalendarToolHarness();
h.Http.Json(CalendarToolHarness.EventsRoute, (HttpStatusCode.Forbidden, """{"error":{"code":403,"message":"Rate Limit Exceeded"}}"""));
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => h.RunAsync(new { action = "list" }));
Assert.Equal("Google Calendar list failed: 403 Rate Limit Exceeded", ex.Message);
Assert.Single(h.CalendarRequests); // no retry
}
[Fact]
public async Task An_unknown_action_is_rejected()
{
using var h = new CalendarToolHarness();
var ex = await Assert.ThrowsAsync<ArgumentException>(() => h.RunAsync(new { action = "purge" }));
Assert.StartsWith("Unknown action \"purge\".", ex.Message);
}
[Fact]
public void Description_and_guideline_state_the_time_rules()
{
using var h = new CalendarToolHarness();
var tool = new CalendarTool(h.Settings, h.Connections, () => "Europe/Berlin");
Assert.Contains("without an offset are in Europe/Berlin", tool.Description);
var guideline = Assert.Single(tool.PromptGuidelines);
Assert.Contains("Europe/Berlin", guideline);
Assert.Contains("exclusive", guideline);
Assert.Contains("list_instances", guideline);
Assert.Equal(Abstractions.Tools.ToolExecutionMode.Parallel, tool.ExecutionMode);
Assert.Equal(JsonValueKind.Object, tool.Parameters.ValueKind);
Assert.Equal("action", tool.Parameters.GetProperty("required")[0].GetString());
}
}

View File

@@ -0,0 +1,226 @@
using System.Net;
using System.Text.Json.Nodes;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
public class CalendarToolWriteTests
{
private const string EventsPost = "POST /calendar/v3/calendars/primary/events";
private static string EventRoute(string method, string id) => $"{method} /calendar/v3/calendars/primary/events/{id}";
private static readonly string Master = Ev.Timed("ev-1", "Standup", "2026-09-28T10:00:00+02:00", "2026-09-28T10:15:00+02:00", recurrence: ["RRULE:FREQ=WEEKLY;BYDAY=MO"]);
private static readonly string Occurrence = Ev.Timed("ev-1_20260928", "Standup", "2026-09-28T10:00:00+02:00", "2026-09-28T10:15:00+02:00", recurringEventId: "ev-1");
private static readonly string Plain = Ev.Timed("ev-2", "Dentist", "2026-10-01T14:00:00+02:00", "2026-10-01T15:00:00+02:00");
private static CalendarToolHarness WithEvent(string id, string json)
{
var h = new CalendarToolHarness();
h.Http.Json(EventRoute("GET", id), json);
h.Http.Json(EventRoute("PATCH", id), json);
h.Http.Json(EventRoute("DELETE", id), (HttpStatusCode.NoContent, ""));
return h;
}
private static JsonObject PostBody(CalendarToolHarness h) => Assert.Single(h.Http.To(EventsPost)).Json;
private static JsonObject PatchBody(CalendarToolHarness h, string id) => Assert.Single(h.Http.To(EventRoute("PATCH", id))).Json;
[Fact]
public async Task Create_sends_recurrence_in_the_body()
{
using var h = new CalendarToolHarness();
h.Http.Json(EventsPost, Master);
var result = await h.RunAsync(new { action = "create", summary = "Standup", start = "2026-09-28T10:00", end = "2026-09-28T10:15", recurrence = new[] { "RRULE:FREQ=WEEKLY;BYDAY=MO,WE" } });
var body = PostBody(h);
Assert.Equal("""["RRULE:FREQ=WEEKLY;BYDAY=MO,WE"]""", body["recurrence"]!.ToJsonString());
Assert.Equal("Standup", body["summary"]!.GetValue<string>());
Assert.StartsWith("Times in Europe/Berlin.\nCreated: ev-1 · Standup", result.TextContent);
}
[Fact]
public async Task Create_rejects_recurrence_lines_with_an_unknown_prefix()
{
using var h = new CalendarToolHarness();
var ex = await Assert.ThrowsAsync<ArgumentException>(() => h.RunAsync(new { action = "create", summary = "x", start = "2026-09-28", recurrence = new[] { "FREQ=WEEKLY" } }));
Assert.Equal("Recurrence line \"FREQ=WEEKLY\" must start with RRULE:, EXDATE:, RDATE: or EXRULE:.", ex.Message);
Assert.Empty(h.CalendarRequests);
}
[Fact]
public async Task Create_sends_an_offset_less_date_time_without_offset_and_with_the_zone()
{
using var h = new CalendarToolHarness();
h.Http.Json(EventsPost, Plain);
await h.RunAsync(new { action = "create", summary = "Dentist", start = "2026-10-01T14:00" });
var body = PostBody(h);
Assert.Equal("""{"dateTime":"2026-10-01T14:00:00","timeZone":"Europe/Berlin"}""", body["start"]!.ToJsonString());
Assert.Equal("""{"dateTime":"2026-10-01T15:00:00","timeZone":"Europe/Berlin"}""", body["end"]!.ToJsonString());
}
[Fact]
public async Task Create_uses_the_time_zone_argument_for_the_event()
{
using var h = new CalendarToolHarness();
h.Http.Json(EventsPost, Plain);
await h.RunAsync(new { action = "create", summary = "Call", start = "2026-10-01T09:00", timeZone = "America/New_York" });
Assert.Equal("America/New_York", PostBody(h)["start"]!["timeZone"]!.GetValue<string>());
var ex = await Assert.ThrowsAsync<ArgumentException>(() => h.RunAsync(new { action = "create", summary = "Call", start = "2026-10-01T09:00", timeZone = "Mars/Base" }));
Assert.StartsWith("Unknown time zone \"Mars/Base\"", ex.Message);
}
[Fact]
public async Task Create_all_day_defaults_end_to_the_next_day()
{
using var h = new CalendarToolHarness();
h.Http.Json(EventsPost, Ev.AllDay("ev-3", "Holiday", "2026-10-03", "2026-10-04"));
await h.RunAsync(new { action = "create", summary = "Holiday", start = "2026-10-03" });
var body = PostBody(h);
Assert.Equal("""{"date":"2026-10-03"}""", body["start"]!.ToJsonString());
Assert.Equal("""{"date":"2026-10-04"}""", body["end"]!.ToJsonString());
}
[Fact]
public async Task Create_rejects_mixed_date_and_date_time()
{
using var h = new CalendarToolHarness();
var ex = await Assert.ThrowsAsync<ArgumentException>(() => h.RunAsync(new { action = "create", summary = "x", start = "2026-10-03", end = "2026-10-03T12:00" }));
Assert.Equal("start and end must both be dates (YYYY-MM-DD) or both date-times.", ex.Message);
}
[Theory]
[InlineData(null, "2026-10-03", "create needs summary.")]
[InlineData("x", null, "create needs start.")]
public async Task Create_needs_summary_and_start(string? summary, string? start, string message)
{
using var h = new CalendarToolHarness();
var ex = await Assert.ThrowsAsync<ArgumentException>(() => h.RunAsync(new { action = "create", summary, start }));
Assert.Equal(message, ex.Message);
}
[Fact]
public async Task Update_auto_detects_an_instance()
{
using var h = WithEvent("ev-1_20260928", Occurrence);
var result = await h.RunAsync(new { action = "update", eventId = "ev-1_20260928", summary = "Standup (moved)" });
Assert.Contains("\nUpdated this occurrence only: ev-1_20260928", result.TextContent);
}
[Fact]
public async Task Update_auto_detects_a_series()
{
using var h = WithEvent("ev-1", Master);
var result = await h.RunAsync(new { action = "update", eventId = "ev-1", location = "Room 2" });
Assert.Contains("\nUpdated the whole series: ev-1", result.TextContent);
}
[Fact]
public async Task Explicit_instance_scope_on_a_series_is_rejected()
{
using var h = WithEvent("ev-1", Master);
var ex = await Assert.ThrowsAsync<ArgumentException>(() => h.RunAsync(new { action = "update", eventId = "ev-1", scope = "instance", summary = "x" }));
Assert.Equal("Event ev-1 is a recurring series. To change one date, call list_instances with eventId=ev-1, then pass that occurrence's id with scope=instance.", ex.Message);
Assert.Empty(h.Http.To(EventRoute("PATCH", "ev-1")));
}
[Fact]
public async Task Explicit_series_scope_on_an_occurrence_is_rejected()
{
using var h = WithEvent("ev-1_20260928", Occurrence);
var ex = await Assert.ThrowsAsync<ArgumentException>(() => h.RunAsync(new { action = "delete", eventId = "ev-1_20260928", scope = "series" }));
Assert.Equal("Event ev-1_20260928 is one occurrence of the recurring series ev-1. To change the whole series, pass eventId=ev-1 with scope=series.", ex.Message);
Assert.Empty(h.Http.To(EventRoute("DELETE", "ev-1_20260928")));
}
[Fact]
public async Task An_explicit_scope_on_a_single_event_is_ignored()
{
using var h = WithEvent("ev-2", Plain);
var result = await h.RunAsync(new { action = "update", eventId = "ev-2", scope = "series", summary = "Dentist!" });
Assert.Contains("\nUpdated: ev-2", result.TextContent);
}
[Fact]
public async Task An_invalid_scope_value_is_rejected()
{
using var h = WithEvent("ev-2", Plain);
var ex = await Assert.ThrowsAsync<ArgumentException>(() => h.RunAsync(new { action = "update", eventId = "ev-2", scope = "following", summary = "x" }));
Assert.Equal("scope must be instance or series.", ex.Message);
}
[Fact]
public async Task Recurrence_on_an_instance_is_rejected()
{
using var h = WithEvent("ev-1_20260928", Occurrence);
var ex = await Assert.ThrowsAsync<ArgumentException>(() => h.RunAsync(new { action = "update", eventId = "ev-1_20260928", recurrence = new[] { "RRULE:FREQ=DAILY" } }));
Assert.Equal("recurrence can't be set on a single occurrence; update the series (eventId=ev-1) instead.", ex.Message);
}
[Fact]
public async Task Update_patch_carries_only_the_given_fields()
{
using var h = WithEvent("ev-2", Plain);
await h.RunAsync(new { action = "update", eventId = "ev-2", summary = "New title" });
Assert.Equal("""{"summary":"New title"}""", PatchBody(h, "ev-2").ToJsonString());
}
[Fact]
public async Task Update_times_follow_the_time_rules()
{
using var h = WithEvent("ev-2", Plain);
await h.RunAsync(new { action = "update", eventId = "ev-2", start = "2026-10-01T16:00", end = "2026-10-01T17:00" });
var body = PatchBody(h, "ev-2");
Assert.Equal("""{"dateTime":"2026-10-01T16:00:00","timeZone":"Europe/Berlin"}""", body["start"]!.ToJsonString());
Assert.Equal(["start", "end"], body.Select(p => p.Key).ToArray());
}
[Fact]
public async Task Update_with_nothing_to_change_is_rejected()
{
using var h = WithEvent("ev-2", Plain);
var ex = await Assert.ThrowsAsync<ArgumentException>(() => h.RunAsync(new { action = "update", eventId = "ev-2" }));
Assert.Equal("update needs at least one field to change (summary, description, location, start, end or recurrence).", ex.Message);
}
[Fact]
public async Task Update_with_an_empty_recurrence_makes_the_series_a_single_event()
{
using var h = WithEvent("ev-1", Master);
await h.RunAsync(new { action = "update", eventId = "ev-1", recurrence = Array.Empty<string>() });
Assert.Equal("""{"recurrence":[]}""", PatchBody(h, "ev-1").ToJsonString());
}
[Fact]
public async Task Delete_of_an_instance_cancels_one_occurrence()
{
using var h = WithEvent("ev-1_20260928", Occurrence);
var result = await h.RunAsync(new { action = "delete", eventId = "ev-1_20260928" });
Assert.Single(h.Http.To(EventRoute("DELETE", "ev-1_20260928")));
Assert.EndsWith("Cancelled one occurrence of \"Standup\" (Mon 28 Sep 10:00–10:15); the rest of the series is unchanged.", result.TextContent);
}
[Fact]
public async Task Delete_of_a_series_deletes_all_occurrences()
{
using var h = WithEvent("ev-1", Master);
var result = await h.RunAsync(new { action = "delete", eventId = "ev-1" });
Assert.EndsWith("Deleted the recurring series \"Standup\" and all its occurrences.", result.TextContent);
}
[Fact]
public async Task Delete_of_a_single_event_deletes_it()
{
using var h = WithEvent("ev-2", Plain);
var result = await h.RunAsync(new { action = "delete", eventId = "ev-2" });
Assert.EndsWith("Deleted \"Dentist\" (Thu 1 Oct 14:00–15:00).", result.TextContent);
}
[Theory]
[InlineData("update", "update needs eventId.")]
[InlineData("delete", "delete needs eventId.")]
public async Task Update_and_delete_need_an_event_id(string action, string message)
{
using var h = new CalendarToolHarness();
var ex = await Assert.ThrowsAsync<ArgumentException>(() => h.RunAsync(new { action }));
Assert.Equal(message, ex.Message);
}
}

View File

@@ -0,0 +1,34 @@
using System.Security.Cryptography;
using System.Text;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
public class ChatKeysTests
{
[Theory]
[InlineData("telegram-111111111")]
[InlineData("a.b_c-D9")]
public void A_safe_scope_is_its_own_key(string scope) => Assert.Equal(scope, ChatKeys.For(scope));
[Theory]
[InlineData("a/b")]
[InlineData("..")]
[InlineData(".")]
[InlineData("")]
[InlineData("has space")]
[InlineData("c:\\x")]
[InlineData("telegram-1\n")] // a trailing newline: '$' alone would accept it
public void An_unsafe_scope_maps_to_a_hashed_key(string scope)
{
var expected = "scope-" + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(scope)))[..16];
Assert.Equal(expected, ChatKeys.For(scope));
}
[Fact]
public void A_scope_longer_than_100_characters_is_hashed()
{
Assert.Equal(new string('a', 100), ChatKeys.For(new string('a', 100)));
Assert.StartsWith("scope-", ChatKeys.For(new string('a', 101)));
}
}

View File

@@ -0,0 +1,247 @@
using System.Net;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
public class ConnectFlowTests
{
private const string Connect = "calendar_connect";
[Fact]
public async Task The_link_and_code_arrive_in_one_notify()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending);
var ui = new CalendarRecordingUi();
var command = h.Run(Connect, h.Context(ui));
await FlowDriver.ParkedAsync(h.Connection());
var (message, kind) = Assert.Single(ui.Notes);
Assert.Equal("Connect Google Calendar:\n1. Open https://www.google.com/device\n2. Enter the code ABCD-EFGH\n3. Approve access. I'll confirm here when it's done (code valid for 30 min).", message);
Assert.Equal(NotifyKind.Info, kind);
h.Connection().CancelPending();
await command.WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task Pending_pending_success_writes_the_store_and_notifies_once()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending, ConnectHarness.Pending, ConnectHarness.Approved());
var ui = new CalendarRecordingUi();
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
Assert.Equal(3, h.Http.To(ConnectHarness.TokenRoute).Count);
Assert.Equal("Bearer at-1", Assert.Single(h.Http.To(ConnectHarness.PrimaryRoute)).Authorization);
var stored = h.Connection().Store.Read();
Assert.Equal(new StoredToken("rt-1", "https://www.googleapis.com/auth/calendar", "me@example.com", "Europe/Berlin", ConnectHarness.Now.AddSeconds(15)), stored);
Assert.Equal(2, ui.Notes.Count);
Assert.Equal(("✅ Google Calendar connected as me@example.com (time zone Europe/Berlin).", NotifyKind.Info), ui.Notes[1]);
Assert.Null(h.Connection().Pending);
}
[Fact]
public async Task Slow_down_widens_the_poll_interval_by_five_seconds()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending, ConnectHarness.SlowDown, ConnectHarness.Pending, ConnectHarness.Approved());
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(new CalendarRecordingUi())), h.Connection(), h.Time);
var polls = h.Http.To(ConnectHarness.TokenRoute).Select(r => r.At).ToList();
Assert.Equal(ConnectHarness.Now.AddSeconds(5), polls[0]);
Assert.Equal([TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10)],
polls.Zip(polls.Skip(1), (a, b) => b - a).ToArray());
}
[Fact]
public async Task Denied_notifies_a_warning_and_writes_nothing()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, (HttpStatusCode.Forbidden, """{"error":"access_denied"}"""));
var ui = new CalendarRecordingUi();
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
Assert.Equal(("Google Calendar access was denied.", NotifyKind.Warning), ui.Notes[^1]);
Assert.Null(h.Connection().Store.Read());
Assert.Null(h.Connection().Pending);
}
[Fact]
public async Task Expired_token_from_google_notifies_the_expiry_warning()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, (HttpStatusCode.BadRequest, """{"error":"expired_token"}"""));
var ui = new CalendarRecordingUi();
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
Assert.Equal(("The Google code expired. Run /calendar_connect again.", NotifyKind.Warning), ui.Notes[^1]);
}
[Fact]
public async Task The_code_lifetime_ending_stops_polling_with_the_expiry_warning()
{
using var h = new ConnectHarness();
h.ScriptDevice("dev-1", "ABCD-EFGH", expiresIn: 12, interval: 5);
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending);
var ui = new CalendarRecordingUi();
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
Assert.Equal(2, h.Http.To(ConnectHarness.TokenRoute).Count); // at +5 and +10; +15 is past +12
Assert.Equal(("The Google code expired. Run /calendar_connect again.", NotifyKind.Warning), ui.Notes[^1]);
}
[Fact]
public async Task Three_consecutive_transport_errors_fail_the_flow()
{
using var h = new ConnectHarness();
h.Http.Fail(ConnectHarness.TokenRoute, "network down");
var ui = new CalendarRecordingUi();
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
Assert.Equal(3, h.Http.To(ConnectHarness.TokenRoute).Count);
Assert.Equal(("Google Calendar connection failed: network down", NotifyKind.Error), ui.Notes[^1]);
}
[Fact]
public async Task Two_transport_errors_then_approval_still_connects()
{
using var h = new ConnectHarness();
h.Http.On(ConnectHarness.TokenRoute, GoogleFakeHttp.Throws(), GoogleFakeHttp.Throws(),
GoogleFakeHttp.Returns(ConnectHarness.Approved().Item1, ConnectHarness.Approved().Item2));
var ui = new CalendarRecordingUi();
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
Assert.StartsWith("✅ Google Calendar connected", ui.Notes[^1].Message);
}
[Fact]
public async Task A_failed_poll_is_reported_as_an_error()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, (HttpStatusCode.Unauthorized, """{"error":"invalid_client","error_description":"Unauthorized"}"""));
var ui = new CalendarRecordingUi();
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
Assert.Equal(("Google Calendar connection failed: invalid_client: Unauthorized", NotifyKind.Error), ui.Notes[^1]);
}
[Fact]
public async Task A_failed_primary_lookup_still_saves_the_token()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Approved());
h.Http.Json(ConnectHarness.PrimaryRoute, (HttpStatusCode.InternalServerError, "{}"));
var ui = new CalendarRecordingUi();
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
var stored = h.Connection().Store.Read()!;
Assert.Equal("rt-1", stored.RefreshToken);
Assert.Null(stored.Account);
Assert.Equal("✅ Google Calendar connected as an unknown account (time zone unknown).", ui.Notes[^1].Message);
}
[Fact]
public async Task A_store_write_failure_is_reported()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Approved());
Directory.CreateDirectory(h.Connection().Store.Path); // a directory where token.json should go
var ui = new CalendarRecordingUi();
await FlowDriver.CompleteAsync(h.Run(Connect, h.Context(ui)), h.Connection(), h.Time);
Assert.StartsWith("Got a Google token but could not save it: ", ui.Notes[^1].Message);
Assert.Equal(NotifyKind.Error, ui.Notes[^1].Kind);
}
[Fact]
public async Task A_second_connect_resends_the_same_code_without_a_new_device_code_request()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending);
var ui = new CalendarRecordingUi();
var first = h.Run(Connect, h.Context(ui));
await FlowDriver.ParkedAsync(h.Connection());
h.Time.Advance(TimeSpan.FromSeconds(1));
await h.Run(Connect, h.Context(ui)).WaitAsync(TimeSpan.FromSeconds(5));
Assert.Single(h.Http.To(ConnectHarness.DeviceRoute));
Assert.Equal("A Google Calendar connection is already waiting for approval:\n1. Open https://www.google.com/device\n2. Enter the code ABCD-EFGH\n(30 min left.)", ui.Notes[1].Message);
h.Connection().CancelPending();
await first.WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task A_connect_after_the_code_expired_starts_a_new_flow_instead_of_resending_the_dead_code()
{
using var h = new ConnectHarness();
h.ScriptDevice("dev-1", "ABCD-EFGH", expiresIn: 10, interval: 30); // parked until +30, dead at +10
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending);
var ui = new CalendarRecordingUi();
var first = h.Run(Connect, h.Context(ui));
var stale = await FlowDriver.ParkedAsync(h.Connection());
h.Time.Advance(TimeSpan.FromSeconds(11));
var statusUi = new CalendarRecordingUi();
await h.Run("calendar_status", h.Context(statusUi));
Assert.Equal("Not connected. Run /calendar_connect.", Assert.Single(statusUi.Notes).Message);
await first.WaitAsync(TimeSpan.FromSeconds(5)); // the stale flow was cancelled: it ends silently
h.ScriptDevice("dev-2", "WXYZ-1234");
var second = h.Run(Connect, h.Context(ui));
var fresh = await FlowDriver.ParkedAsync(h.Connection());
Assert.NotSame(stale, fresh);
Assert.Equal(2, h.Http.To(ConnectHarness.DeviceRoute).Count);
Assert.Equal(2, ui.Notes.Count); // two links, no "already waiting", no late expiry warning
Assert.Contains("WXYZ-1234", ui.Notes[1].Message);
h.Connection().CancelPending();
await second.WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task A_cancelled_flow_that_has_not_ended_yet_is_not_resent()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending);
var ui = new CalendarRecordingUi();
var first = h.Run(Connect, h.Context(ui));
var stale = await FlowDriver.ParkedAsync(h.Connection());
stale.Cancel();
Assert.Null(h.Connection().Pending);
await first.WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task Cancelling_a_pending_flow_is_silent_and_clears_it()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending);
var ui = new CalendarRecordingUi();
var command = h.Run(Connect, h.Context(ui));
await FlowDriver.ParkedAsync(h.Connection());
h.Connection().CancelPending();
await command.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Single(ui.Notes); // only the link
Assert.Null(h.Connection().Pending);
}
[Fact]
public async Task A_device_code_failure_is_reported_and_leaves_nothing_pending()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.DeviceRoute, (HttpStatusCode.Unauthorized, """{"error":"invalid_client"}"""));
var ui = new CalendarRecordingUi();
await h.Run(Connect, h.Context(ui)).WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(("Google Calendar connection failed: Google refused the device code request: invalid_client", NotifyKind.Error), Assert.Single(ui.Notes));
Assert.Null(h.Connection().Pending);
}
[Fact]
public async Task Without_a_ui_the_command_appends_a_custom_message()
{
using var h = new ConnectHarness();
await h.Run(Connect, h.Context(ui: null));
var (message, delivery, triggerTurn) = Assert.Single(h.Api.Sent);
Assert.Equal("calendar_connect", message.CustomType);
Assert.Equal("/calendar_connect needs an interactive UI (a chat front end such as the Telegram bot).", message.Text);
Assert.False(triggerTurn);
Assert.Empty(h.Http.Requests);
}
}

View File

@@ -0,0 +1,156 @@
using System.Net;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Time.Testing;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
public class GoogleCalendarConnectionTests : IDisposable
{
private static readonly DateTimeOffset Now = new(2026, 9, 26, 8, 0, 0, TimeSpan.Zero);
private readonly string _root = Directory.CreateTempSubdirectory("gcal-conn-").FullName;
private readonly FakeTimeProvider _time = new(Now);
private readonly GoogleFakeHttp _http;
private readonly GoogleCalendarSettings _settings;
private readonly GoogleCalendarConnections _connections = new();
public GoogleCalendarConnectionTests()
{
_http = new GoogleFakeHttp(_time);
_settings = new GoogleCalendarSettings("cid", "csecret", _root, _http.CreateClient(), _time);
_http.Json("POST /token", """{"access_token":"at-1","expires_in":3600}""");
}
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
}
private GoogleCalendarConnection Connected(string scope = "telegram-1", string refreshToken = "rt-1", string? zone = null)
{
var connection = _connections.For(_settings, scope);
connection.Store.Write(new StoredToken(refreshToken, TimeZone: zone));
return connection;
}
private int Refreshes => _http.To("POST /token").Count;
[Fact]
public void Two_scopes_get_two_token_files_under_their_own_directories()
{
var a = _connections.For(_settings, "telegram-1");
var b = _connections.For(_settings, "telegram-2");
Assert.Equal(Path.Combine(_root, "telegram-1", "token.json"), a.Store.Path);
Assert.Equal(Path.Combine(_root, "telegram-2", "token.json"), b.Store.Path);
Assert.Same(a, _connections.For(_settings, "telegram-1"));
Assert.NotSame(a, b);
Assert.StartsWith(Path.Combine(_root, "scope-"), _connections.For(_settings, "../escape").Store.Path);
}
[Fact]
public async Task Not_connected_throws_the_connect_hint()
{
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => _connections.For(_settings, "telegram-1").GetAccessTokenAsync(CancellationToken.None));
Assert.True(ex.NotConnected);
Assert.Equal("Google Calendar is not connected. Ask the user to run /calendar_connect in this chat.", ex.Message);
Assert.Empty(_http.Requests);
}
[Fact]
public async Task Reuses_the_cached_access_token_while_valid()
{
var connection = Connected();
Assert.Equal("at-1", await connection.GetAccessTokenAsync(CancellationToken.None));
_time.Advance(TimeSpan.FromSeconds(3539));
Assert.Equal("at-1", await connection.GetAccessTokenAsync(CancellationToken.None));
Assert.Equal(1, Refreshes);
}
[Fact]
public async Task Refreshes_within_60_seconds_of_expiry()
{
var connection = Connected();
await connection.GetAccessTokenAsync(CancellationToken.None);
_http.Json("POST /token", """{"access_token":"at-2","expires_in":3600}""");
_time.Advance(TimeSpan.FromSeconds(3541));
Assert.Equal("at-2", await connection.GetAccessTokenAsync(CancellationToken.None));
Assert.Equal(2, Refreshes);
}
[Fact]
public async Task A_changed_refresh_token_drops_the_cache()
{
var connection = Connected(refreshToken: "rt-1");
await connection.GetAccessTokenAsync(CancellationToken.None);
connection.Store.Write(new StoredToken("rt-2")); // a reconnect wrote a new token
await connection.GetAccessTokenAsync(CancellationToken.None);
Assert.Equal(2, Refreshes);
Assert.Equal("rt-2", _http.To("POST /token")[1].Form["refresh_token"]);
}
[Fact]
public async Task Concurrent_callers_share_one_refresh()
{
var connection = Connected();
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_http.On("POST /token", async _ =>
{
await gate.Task;
return GoogleFakeHttp.Response(HttpStatusCode.OK, """{"access_token":"at-9","expires_in":3600}""");
});
var calls = Enumerable.Range(0, 5).Select(_ => connection.GetAccessTokenAsync(CancellationToken.None)).ToList();
await Eventually.UntilAsync(() => Refreshes == 1, "the first refresh");
gate.SetResult();
Assert.All(await Task.WhenAll(calls), t => Assert.Equal("at-9", t));
Assert.Equal(1, Refreshes);
}
[Fact]
public async Task Invalidate_forces_a_refresh()
{
var connection = Connected();
await connection.GetAccessTokenAsync(CancellationToken.None);
connection.Invalidate();
await connection.GetAccessTokenAsync(CancellationToken.None);
Assert.Equal(2, Refreshes);
}
[Fact]
public async Task Invalid_grant_throws_not_connected_and_keeps_the_file()
{
var connection = Connected();
_http.Json("POST /token", (HttpStatusCode.BadRequest, """{"error":"invalid_grant"}"""));
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => connection.GetAccessTokenAsync(CancellationToken.None));
Assert.True(ex.NotConnected);
Assert.True(File.Exists(connection.Store.Path));
}
[Fact]
public void Resolved_zone_is_the_calendars_stored_zone()
{
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
var zone = Connected(zone: "America/New_York").ResolveTimeZone(berlin);
Assert.Equal("America/New_York", ZoneIds.Name(zone));
}
[Fact]
public void Resolved_zone_falls_back_to_the_chat_zone()
{
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
Assert.Same(berlin, _connections.For(_settings, "telegram-9").ResolveTimeZone(berlin)); // not connected
Assert.Same(berlin, Connected(zone: null).ResolveTimeZone(berlin));
}
[Fact]
public void An_unknown_stored_zone_is_logged_once_and_falls_back()
{
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
var logger = new ListLogger();
var connection = Connected(zone: "Mars/Olympus_Mons");
Assert.Same(berlin, connection.ResolveTimeZone(berlin, logger));
Assert.Same(berlin, connection.ResolveTimeZone(berlin, logger));
var entry = Assert.Single(logger.Entries);
Assert.Equal(LogLevel.Warning, entry.Level);
Assert.Contains("Mars/Olympus_Mons", entry.Message);
}
}

View File

@@ -0,0 +1,90 @@
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
public class GoogleCalendarExtensionTests
{
[Fact]
public async Task Without_a_client_it_registers_nothing_and_logs_one_info_line()
{
var logger = new ListLogger();
var api = new CalendarFakeApi(logger);
var extension = new GoogleCalendarExtension(new GoogleCalendarOptions { GetEnv = _ => null });
Assert.Equal("google-calendar", extension.Name);
await extension.ActivateAsync(api, CancellationToken.None);
Assert.Empty(api.Tools);
Assert.Empty(api.Commands);
var entry = Assert.Single(logger.Entries);
Assert.Equal((LogLevel.Information, "google-calendar disabled: set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET"), entry);
}
[Theory]
[InlineData("cid", null, "GOOGLE_CLIENT_SECRET")]
[InlineData(null, "csecret", "GOOGLE_CLIENT_ID")]
public async Task With_only_one_of_id_and_secret_it_logs_a_warning(string? id, string? secret, string missing)
{
var logger = new ListLogger();
var api = new CalendarFakeApi(logger);
await new GoogleCalendarExtension(new GoogleCalendarOptions { ClientId = id, ClientSecret = secret, GetEnv = _ => null }).ActivateAsync(api, CancellationToken.None);
Assert.Empty(api.Tools);
var entry = Assert.Single(logger.Entries);
Assert.Equal(LogLevel.Warning, entry.Level);
Assert.Contains(missing, entry.Message);
Assert.DoesNotContain("csecret", entry.Message);
}
[Fact]
public void Configured_it_registers_the_calendar_tool_and_three_commands()
{
using var h = new ConnectHarness();
Assert.Equal("calendar", Assert.Single(h.Api.Tools).Name);
Assert.Equal(["calendar_connect", "calendar_disconnect", "calendar_status"], h.Api.Commands.Keys.Order().ToArray());
Assert.All(h.Api.Commands.Values, c => Assert.NotNull(c.Description));
}
[Fact]
public async Task After_session_start_the_description_names_the_resolved_zone()
{
using var h = new ConnectHarness();
var tool = Assert.Single(h.Api.Tools);
Assert.Contains("the zone named in each result's header", tool.Description);
await h.Api.RaiseAsync(new SessionStartEvent(SessionStartReason.Startup), h.Context(null));
Assert.Contains("without an offset are in Europe/Berlin", tool.Description);
h.Connection().Store.Write(new StoredToken("rt-1", TimeZone: "America/New_York"));
Assert.Contains("without an offset are in America/New_York", Assert.Single(tool.PromptGuidelines));
}
[Fact]
public async Task An_unknown_stored_zone_read_first_by_the_description_is_still_logged_exactly_once()
{
var logger = new ListLogger();
using var h = new ConnectHarness(logger);
var tool = Assert.Single(h.Api.Tools);
await h.Api.RaiseAsync(new SessionStartEvent(SessionStartReason.Startup), h.Context(null));
h.Connection().Store.Write(new StoredToken("rt-1", TimeZone: "Mars/Olympus_Mons"));
Assert.Contains("without an offset are in Europe/Berlin", tool.Description); // read before any tool call
_ = tool.PromptGuidelines;
h.Connection().ResolveTimeZone(ConnectHarness.Berlin, logger); // what the tool's execution does
var entry = Assert.Single(logger.Entries, e => e.Level == LogLevel.Warning);
Assert.Contains("Mars/Olympus_Mons", entry.Message);
}
[Fact]
public void A_logger_less_zone_lookup_does_not_use_up_the_log_once_flag()
{
using var h = new ConnectHarness();
var connection = h.Connection();
connection.Store.Write(new StoredToken("rt-1", TimeZone: "Mars/Olympus_Mons"));
connection.ResolveTimeZone(ConnectHarness.Berlin); // no logger
var logger = new ListLogger();
connection.ResolveTimeZone(ConnectHarness.Berlin, logger);
connection.ResolveTimeZone(ConnectHarness.Berlin, logger);
Assert.Single(logger.Entries);
}
}

View File

@@ -0,0 +1,73 @@
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
public class GoogleCalendarOptionsTests
{
private static Func<string, string?> Env(params (string Name, string Value)[] vars) =>
name => vars.FirstOrDefault(v => v.Name == name).Value;
[Fact]
public void Explicit_values_win_over_the_environment()
{
var options = new GoogleCalendarOptions
{
ClientId = "cid", ClientSecret = "csecret",
GetEnv = Env(("GOOGLE_CLIENT_ID", "env-id"), ("GOOGLE_CLIENT_SECRET", "env-secret")),
};
Assert.Equal("cid", options.EffectiveClientId);
Assert.Equal("csecret", options.EffectiveClientSecret);
}
[Fact]
public void Empty_values_count_as_unset_and_fall_back_to_the_environment()
{
var options = new GoogleCalendarOptions
{
ClientId = "", ClientSecret = " ",
GetEnv = Env(("GOOGLE_CLIENT_ID", "env-id"), ("GOOGLE_CLIENT_SECRET", "env-secret")),
};
Assert.Equal("env-id", options.EffectiveClientId);
Assert.Equal("env-secret", options.EffectiveClientSecret);
}
[Theory]
[InlineData(null, null)]
[InlineData("cid", null)]
[InlineData(null, "csecret")]
[InlineData("", "")]
public void Resolve_returns_null_unless_both_id_and_secret_are_set(string? id, string? secret)
{
var options = new GoogleCalendarOptions { ClientId = id, ClientSecret = secret, GetEnv = _ => null };
Assert.Null(options.Resolve());
}
[Fact]
public void Resolve_fills_http_time_and_token_root()
{
var root = Path.Combine(Path.GetTempPath(), "gcal-root");
var settings = new GoogleCalendarOptions { ClientId = "cid", ClientSecret = "csecret", TokenDirectory = root, GetEnv = _ => null }.Resolve();
Assert.NotNull(settings);
Assert.Equal(root, settings.TokenRoot);
Assert.Same(TimeProvider.System, settings.Time);
Assert.NotNull(settings.Http);
}
[Fact]
public void Token_directory_defaults_under_the_agent_dir()
{
var agentDir = Path.Combine(Path.GetTempPath(), "agent-dir");
Assert.Equal(Path.Combine(agentDir, "google-calendar"),
new GoogleCalendarOptions { GetEnv = Env(("VOLCANIC_AGENT_DIR", agentDir)) }.EffectiveTokenDirectory);
Assert.Equal(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".volcanic", "agent", "google-calendar"),
new GoogleCalendarOptions { GetEnv = _ => null }.EffectiveTokenDirectory);
}
[Fact]
public void ToString_never_shows_the_client_secret()
{
var options = new GoogleCalendarOptions { ClientId = "cid", ClientSecret = "super-secret-value", GetEnv = _ => null };
Assert.DoesNotContain("super-secret-value", options.ToString());
Assert.DoesNotContain("super-secret-value", options.Resolve()!.ToString());
}
}

View File

@@ -0,0 +1,173 @@
using System.Net;
using Microsoft.Extensions.Time.Testing;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
public class GoogleOAuthClientTests
{
private static readonly DateTimeOffset Now = new(2026, 9, 26, 8, 0, 0, TimeSpan.Zero);
private readonly FakeTimeProvider _time = new(Now);
private readonly GoogleFakeHttp _http;
private readonly GoogleOAuthClient _client;
public GoogleOAuthClientTests()
{
_http = new GoogleFakeHttp(_time);
_client = new GoogleOAuthClient(_http.CreateClient(), "cid", "csecret", _time);
}
[Fact]
public async Task Start_posts_the_client_id_and_the_calendar_scope()
{
_http.Json("POST /device/code", """{"device_code":"dev-1","user_code":"ABCD-EFGH","verification_url":"https://www.google.com/device","expires_in":1800,"interval":5}""");
var device = await _client.StartDeviceFlowAsync(CancellationToken.None);
var request = Assert.Single(_http.Requests);
Assert.Equal("https://oauth2.googleapis.com/device/code", request.Uri.ToString());
Assert.Equal(new Dictionary<string, string> { ["client_id"] = "cid", ["scope"] = "https://www.googleapis.com/auth/calendar" }, request.Form);
Assert.Equal(new DeviceAuthorization("dev-1", "ABCD-EFGH", "https://www.google.com/device", Now.AddSeconds(1800), TimeSpan.FromSeconds(5)), device);
}
[Fact]
public async Task Start_accepts_verification_uri_and_defaults_the_interval_to_5_seconds()
{
_http.Json("POST /device/code", """{"device_code":"d","user_code":"U","verification_uri":"https://example.test/device","expires_in":600}""");
var device = await _client.StartDeviceFlowAsync(CancellationToken.None);
Assert.Equal("https://example.test/device", device.VerificationUrl);
Assert.Equal(TimeSpan.FromSeconds(5), device.Interval);
}
[Fact]
public async Task Start_error_throws_with_googles_error_and_description()
{
_http.Json("POST /device/code", (HttpStatusCode.Unauthorized, """{"error":"invalid_client","error_description":"The OAuth client was not found."}"""));
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => _client.StartDeviceFlowAsync(CancellationToken.None));
Assert.Contains("invalid_client: The OAuth client was not found.", ex.Message);
Assert.DoesNotContain("csecret", ex.Message);
}
[Fact]
public async Task Poll_posts_the_device_code_grant()
{
_http.Json("POST /token", (HttpStatusCode.PreconditionRequired, """{"error":"authorization_pending"}"""));
await _client.PollAsync("dev-1", CancellationToken.None);
var form = Assert.Single(_http.Requests).Form;
Assert.Equal("cid", form["client_id"]);
Assert.Equal("csecret", form["client_secret"]);
Assert.Equal("dev-1", form["device_code"]);
Assert.Equal("urn:ietf:params:oauth:grant-type:device_code", form["grant_type"]);
}
[Theory]
[InlineData(428)]
[InlineData(400)]
public async Task Poll_maps_authorization_pending_whatever_the_status(int status)
{
_http.Json("POST /token", ((HttpStatusCode)status, """{"error":"authorization_pending","error_description":"Precondition Required"}"""));
Assert.Equal(PollStatus.Pending, (await _client.PollAsync("dev-1", CancellationToken.None)).Status);
}
[Fact]
public async Task Poll_maps_slow_down_at_403()
{
_http.Json("POST /token", (HttpStatusCode.Forbidden, """{"error":"slow_down"}"""));
Assert.Equal(PollStatus.SlowDown, (await _client.PollAsync("dev-1", CancellationToken.None)).Status);
}
[Fact]
public async Task Poll_maps_access_denied()
{
_http.Json("POST /token", (HttpStatusCode.Forbidden, """{"error":"access_denied","error_description":"Forbidden"}"""));
Assert.Equal(PollStatus.Denied, (await _client.PollAsync("dev-1", CancellationToken.None)).Status);
}
[Fact]
public async Task Poll_maps_expired_token()
{
_http.Json("POST /token", (HttpStatusCode.BadRequest, """{"error":"expired_token"}"""));
Assert.Equal(PollStatus.Expired, (await _client.PollAsync("dev-1", CancellationToken.None)).Status);
}
[Fact]
public async Task Poll_maps_an_unknown_error_to_failed_with_its_text()
{
_http.Json("POST /token", (HttpStatusCode.Unauthorized, """{"error":"invalid_client","error_description":"Unauthorized"}"""));
var result = await _client.PollAsync("dev-1", CancellationToken.None);
Assert.Equal(PollStatus.Failed, result.Status);
Assert.Equal("invalid_client: Unauthorized", result.Message);
}
[Fact]
public async Task Poll_500_without_an_error_field_is_a_transport_error()
{
_http.Json("POST /token", (HttpStatusCode.InternalServerError, "<html>oops</html>"));
await Assert.ThrowsAsync<HttpRequestException>(() => _client.PollAsync("dev-1", CancellationToken.None));
}
[Fact]
public async Task Poll_success_returns_the_refresh_and_access_tokens()
{
_http.Json("POST /token", """{"access_token":"at-1","expires_in":3599,"refresh_token":"rt-1","scope":"https://www.googleapis.com/auth/calendar","token_type":"Bearer"}""");
var result = await _client.PollAsync("dev-1", CancellationToken.None);
Assert.Equal(PollStatus.Success, result.Status);
Assert.Equal("rt-1", result.RefreshToken);
Assert.Equal("at-1", result.AccessToken);
Assert.Equal("https://www.googleapis.com/auth/calendar", result.Scope);
Assert.Equal(Now.AddSeconds(3599), result.AccessTokenExpiresAt);
Assert.DoesNotContain("rt-1", result.ToString());
Assert.DoesNotContain("at-1", result.ToString());
}
[Fact]
public async Task Poll_success_without_a_refresh_token_fails_with_the_permissions_hint()
{
_http.Json("POST /token", """{"access_token":"at-1","expires_in":3599}""");
var result = await _client.PollAsync("dev-1", CancellationToken.None);
Assert.Equal(PollStatus.Failed, result.Status);
Assert.Equal("Google returned no refresh token. Remove the app at https://myaccount.google.com/permissions and run /calendar_connect again.", result.Message);
}
[Fact]
public async Task Refresh_returns_the_access_token_and_its_expiry()
{
_http.Json("POST /token", """{"access_token":"at-2","expires_in":3600}""");
var token = await _client.RefreshAsync("rt-1", CancellationToken.None);
Assert.Equal(new AccessToken("at-2", Now.AddSeconds(3600)), token);
var form = Assert.Single(_http.Requests).Form;
Assert.Equal("refresh_token", form["grant_type"]);
Assert.Equal("rt-1", form["refresh_token"]);
Assert.Equal("csecret", form["client_secret"]);
}
[Fact]
public async Task Refresh_invalid_grant_throws_not_connected_without_the_token()
{
_http.Json("POST /token", (HttpStatusCode.BadRequest, """{"error":"invalid_grant","error_description":"Token has been expired or revoked."}"""));
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => _client.RefreshAsync("rt-secret-1", CancellationToken.None));
Assert.True(ex.NotConnected);
Assert.Equal("Google rejected the stored calendar token (revoked or expired). Ask the user to run /calendar_connect in this chat.", ex.Message);
Assert.DoesNotContain("rt-secret-1", ex.ToString());
}
[Fact]
public async Task Refresh_other_errors_throw_with_googles_text()
{
_http.Json("POST /token", (HttpStatusCode.Unauthorized, """{"error":"invalid_client"}"""));
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => _client.RefreshAsync("rt-1", CancellationToken.None));
Assert.False(ex.NotConnected);
Assert.Equal("Google token refresh failed: invalid_client", ex.Message);
}
[Fact]
public async Task Revoke_posts_the_token_and_reports_failures()
{
_http.Json("POST /revoke", (HttpStatusCode.OK, "{}"), (HttpStatusCode.BadRequest, """{"error":"invalid_token"}"""));
Assert.Null(await _client.RevokeAsync("rt-1", CancellationToken.None));
Assert.Equal("rt-1", _http.To("POST /revoke")[0].Form["token"]);
Assert.Equal("invalid_token", await _client.RevokeAsync("rt-1", CancellationToken.None));
_http.Fail("POST /revoke", "no route to host");
Assert.Equal("no route to host", await _client.RevokeAsync("rt-1", CancellationToken.None));
}
}

View File

@@ -0,0 +1,87 @@
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
public class GoogleTokenStoreTests : IDisposable
{
private readonly string _root = Directory.CreateTempSubdirectory("gcal-store-").FullName;
private string TokenPath => Path.Combine(_root, "telegram-1", GoogleTokenStore.FileName);
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
}
private static readonly StoredToken Sample = new("1//0e-refresh", "https://www.googleapis.com/auth/calendar",
"user@gmail.com", "Europe/Berlin", new DateTimeOffset(2026, 9, 26, 10, 0, 0, TimeSpan.Zero));
[Fact]
public void Round_trips_a_token()
{
var store = new GoogleTokenStore(TokenPath);
store.Write(Sample);
Assert.Equal(Sample, store.Read());
var json = File.ReadAllText(TokenPath);
Assert.Contains("\"refreshToken\"", json);
Assert.Contains("\"timeZone\"", json);
Assert.NotEqual(0xEF, File.ReadAllBytes(TokenPath)[0]); // no BOM
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("not json")]
[InlineData("[]")]
[InlineData("{}")]
[InlineData("{\"refreshToken\":\"\"}")]
[InlineData("{\"account\":\"user@gmail.com\"}")]
public void Read_returns_null_for_missing_empty_garbage_or_keyless_files(string? content)
{
if (content is not null)
{
Directory.CreateDirectory(Path.GetDirectoryName(TokenPath)!);
File.WriteAllText(TokenPath, content);
}
Assert.Null(new GoogleTokenStore(TokenPath).Read());
}
[Fact]
public void Write_replaces_the_file_atomically_and_leaves_no_temp_file()
{
var store = new GoogleTokenStore(TokenPath);
store.Write(Sample);
File.WriteAllText(TokenPath + ".tmp", "stale leftover");
store.Write(Sample with { RefreshToken = "1//0e-second" });
Assert.Equal("1//0e-second", store.Read()!.RefreshToken);
Assert.False(File.Exists(TokenPath + ".tmp"));
Assert.Single(Directory.GetFiles(Path.GetDirectoryName(TokenPath)!));
}
[Fact]
public void Write_creates_an_owner_only_file_and_directory_on_unix()
{
if (OperatingSystem.IsWindows()) return;
new GoogleTokenStore(TokenPath).Write(Sample);
Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(TokenPath));
Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute,
File.GetUnixFileMode(Path.GetDirectoryName(TokenPath)!));
}
[Fact]
public void Delete_removes_the_file_and_tolerates_a_missing_one()
{
var store = new GoogleTokenStore(TokenPath);
store.Delete(); // nothing there yet, not even the directory
store.Write(Sample);
store.Delete();
Assert.False(File.Exists(TokenPath));
Assert.Null(store.Read());
}
[Fact]
public void ToString_never_shows_the_refresh_token()
{
Assert.DoesNotContain("1//0e-refresh", Sample.ToString());
Assert.Contains("user@gmail.com", Sample.ToString());
}
}

View File

@@ -0,0 +1,103 @@
using System.Net;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
public class PerChatTests
{
private static Task<ToolResult> ListIn(ConnectHarness h, string scope) =>
Assert.Single(h.Api.Tools).ExecuteAsync(new ToolCallContent("c1", "calendar", VolcanicJson.ToElement(new { action = "list" })),
new ToolInvocation("c1", h.Context(null, scope), null), CancellationToken.None);
[Fact]
public async Task Connecting_chat_a_leaves_chat_b_not_connected()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Approved());
await FlowDriver.CompleteAsync(h.Run("calendar_connect", h.Context(new CalendarRecordingUi(), ConnectHarness.ScopeA)), h.Connection(ConnectHarness.ScopeA), h.Time);
var ui = new CalendarRecordingUi();
await h.Run("calendar_status", h.Context(ui, ConnectHarness.ScopeB));
Assert.Equal("Not connected. Run /calendar_connect.", Assert.Single(ui.Notes).Message);
var ex = await Assert.ThrowsAsync<GoogleCalendarException>(() => ListIn(h, ConnectHarness.ScopeB));
Assert.True(ex.NotConnected);
Assert.True(File.Exists(Path.Combine(h.TokenRoot, ConnectHarness.ScopeA, "token.json")));
Assert.False(Directory.Exists(Path.Combine(h.TokenRoot, ConnectHarness.ScopeB)));
}
[Fact]
public async Task Concurrent_flows_in_two_chats_each_end_with_their_own_store_and_notify()
{
using var h = new ConnectHarness();
var devices = 0;
h.Http.Reply(ConnectHarness.DeviceRoute, _ => Interlocked.Increment(ref devices) == 1
? (HttpStatusCode.OK, """{"device_code":"dev-A","user_code":"AAAA-AAAA","verification_url":"https://www.google.com/device","expires_in":1800,"interval":5}""")
: (HttpStatusCode.OK, """{"device_code":"dev-B","user_code":"BBBB-BBBB","verification_url":"https://www.google.com/device","expires_in":1800,"interval":5}"""));
h.Http.Reply(ConnectHarness.TokenRoute, r => r.Form["device_code"] == "dev-A" ? ConnectHarness.Approved("rt-A", "at-A") : ConnectHarness.Approved("rt-B", "at-B"));
h.Http.Reply(ConnectHarness.PrimaryRoute, r => r.Authorization == "Bearer at-A"
? (HttpStatusCode.OK, """{"id":"a@example.com","timeZone":"Europe/Berlin"}""")
: (HttpStatusCode.OK, """{"id":"b@example.com","timeZone":"Asia/Tokyo"}"""));
var uiA = new CalendarRecordingUi();
var uiB = new CalendarRecordingUi();
var a = h.Run("calendar_connect", h.Context(uiA, ConnectHarness.ScopeA));
await Eventually.UntilAsync(() => h.Connection(ConnectHarness.ScopeA).Pending is not null, "flow A");
var b = h.Run("calendar_connect", h.Context(uiB, ConnectHarness.ScopeB));
await FlowDriver.AdvanceUntilAsync(h.Time, () => a.IsCompleted && b.IsCompleted, TimeSpan.FromSeconds(1));
Assert.Contains("AAAA-AAAA", uiA.Notes[0].Message);
Assert.Contains("BBBB-BBBB", uiB.Notes[0].Message);
Assert.Equal("✅ Google Calendar connected as a@example.com (time zone Europe/Berlin).", uiA.Notes[^1].Message);
Assert.Equal("✅ Google Calendar connected as b@example.com (time zone Asia/Tokyo).", uiB.Notes[^1].Message);
Assert.Equal("rt-A", h.Connection(ConnectHarness.ScopeA).Store.Read()!.RefreshToken);
Assert.Equal("rt-B", h.Connection(ConnectHarness.ScopeB).Store.Read()!.RefreshToken);
}
[Fact]
public async Task Connect_twice_in_a_reuses_a_code_while_b_gets_a_fresh_one()
{
using var h = new ConnectHarness();
var devices = 0;
h.Http.Reply(ConnectHarness.DeviceRoute, _ => (HttpStatusCode.OK,
$$"""{"device_code":"dev-{{Interlocked.Increment(ref devices)}}","user_code":"CODE-{{devices}}","verification_url":"https://www.google.com/device","expires_in":1800,"interval":5}"""));
h.Http.Json(ConnectHarness.TokenRoute, ConnectHarness.Pending);
var uiA = new CalendarRecordingUi();
var uiB = new CalendarRecordingUi();
var first = h.Run("calendar_connect", h.Context(uiA, ConnectHarness.ScopeA));
await FlowDriver.ParkedAsync(h.Connection(ConnectHarness.ScopeA));
await h.Run("calendar_connect", h.Context(uiA, ConnectHarness.ScopeA)).WaitAsync(TimeSpan.FromSeconds(5));
var second = h.Run("calendar_connect", h.Context(uiB, ConnectHarness.ScopeB));
await FlowDriver.ParkedAsync(h.Connection(ConnectHarness.ScopeB));
Assert.Contains("CODE-1", uiA.Notes[1].Message);
Assert.Contains("CODE-2", uiB.Notes[0].Message);
Assert.Equal(2, h.Http.To(ConnectHarness.DeviceRoute).Count);
h.Connection(ConnectHarness.ScopeA).CancelPending();
h.Connection(ConnectHarness.ScopeB).CancelPending();
await Task.WhenAll(first, second).WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task Disconnect_in_a_leaves_b_token_and_cache_alone()
{
using var h = new ConnectHarness();
h.Http.Json(ConnectHarness.TokenRoute, """{"access_token":"at-x","expires_in":3600}""");
h.Http.Json("POST /revoke", "{}");
h.Connection(ConnectHarness.ScopeA).Store.Write(new StoredToken("rt-A"));
h.Connection(ConnectHarness.ScopeB).Store.Write(new StoredToken("rt-B"));
await h.Connection(ConnectHarness.ScopeB).GetAccessTokenAsync(CancellationToken.None);
var refreshes = h.Http.To(ConnectHarness.TokenRoute).Count;
await h.Run("calendar_disconnect", h.Context(new CalendarRecordingUi(), ConnectHarness.ScopeA));
Assert.Equal("rt-A", Assert.Single(h.Http.To("POST /revoke")).Form["token"]);
Assert.Null(h.Connection(ConnectHarness.ScopeA).Store.Read());
Assert.Equal("rt-B", h.Connection(ConnectHarness.ScopeB).Store.Read()!.RefreshToken);
await h.Connection(ConnectHarness.ScopeB).GetAccessTokenAsync(CancellationToken.None);
Assert.Equal(refreshes, h.Http.To(ConnectHarness.TokenRoute).Count); // B's cache survived
}
}

View File

@@ -0,0 +1,202 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Sessions;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
internal sealed class FakeSessionManager(string scope) : IReadOnlySessionManager
{
public string SessionId => "session-1";
public string Scope => scope;
public string Cwd => Path.GetTempPath();
public string? Name => null;
public string? LeafId => null;
public IReadOnlyList<SessionEntry> GetEntries() => [];
public IReadOnlyList<SessionEntry> GetBranch() => [];
public SessionEntry? GetEntry(string id) => null;
}
/// <summary>A command/tool context for one chat: its scope, its zone and (optionally) a UI.</summary>
internal sealed class CalendarFakeContext(string scope, TimeZoneInfo zone, IUserInterface? ui = null, ILogger? logger = null) : IExtensionCommandContext
{
public string Cwd => Path.GetTempPath();
public string SessionId => "session-1";
public bool HasUi => ui is not null;
public IUserInterface Ui => ui ?? NullUserInterface.Instance;
public IReadOnlySessionManager SessionManager { get; } = new FakeSessionManager(scope);
public IModelRegistry ModelRegistry => throw new NotSupportedException();
public Model? Model => null;
public ThinkingLevel ThinkingLevel => ThinkingLevel.Off;
public TimeZoneInfo TimeZone => zone;
public CancellationToken Signal => CancellationToken.None;
public ILogger Logger => logger ?? NullLogger.Instance;
public bool IsIdle() => true;
public void Abort() { }
public bool HasPendingMessages() => false;
public ContextUsage? GetContextUsage() => null;
public string GetSystemPrompt() => "";
public void Compact(CompactOptions? options = null) { }
public Task WaitForIdleAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<SwitchResult> NewSessionAsync(NewSessionOptions? options = null, CancellationToken cancellationToken = default) => Task.FromResult(new SwitchResult(false));
public Task<SwitchResult> SwitchSessionAsync(string sessionId, CancellationToken cancellationToken = default) => Task.FromResult(new SwitchResult(false));
}
/// <summary>Records what the extension registers and sends; everything else is unsupported so misuse is loud.</summary>
internal sealed class CalendarFakeApi(ILogger? logger = null) : IExtensionApi
{
public List<ITool> Tools { get; } = [];
public Dictionary<string, CommandDefinition> Commands { get; } = new();
public List<(Type Event, Delegate Handler)> Handlers { get; } = [];
public List<(CustomMessage Message, MessageDelivery Delivery, bool TriggerTurn)> Sent { get; } = [];
public string ExtensionName => GoogleCalendarExtension.ExtensionName;
public ILogger Logger => logger ?? NullLogger.Instance;
public IEventBus Events => throw new NotSupportedException();
public IDisposable On<TEvent>(ExtensionHandler<TEvent> handler) where TEvent : ExtensionEvent
{
Handlers.Add((typeof(TEvent), handler));
return new Subscription();
}
public async Task RaiseAsync<TEvent>(TEvent e, IExtensionContext ctx) where TEvent : ExtensionEvent
{
foreach (var (type, handler) in Handlers.Where(h => h.Event == typeof(TEvent)))
await ((ExtensionHandler<TEvent>)handler)(e, ctx, CancellationToken.None);
}
public void RegisterTool(ITool tool) => Tools.Add(tool);
public void RegisterCommand(string name, CommandDefinition command) => Commands[name] = command;
public void RegisterProvider(ProviderRegistration provider) => throw new NotSupportedException();
public void UnregisterProvider(string providerId) => throw new NotSupportedException();
public void SendMessage(CustomMessage message, MessageDelivery delivery = MessageDelivery.Steer, bool triggerTurn = true) => Sent.Add((message, delivery, triggerTurn));
public void SendUserMessage(string text, IReadOnlyList<ImageContent>? images = null, MessageDelivery delivery = MessageDelivery.Steer) => throw new NotSupportedException();
public void AppendEntry(string customType, JsonElement? data = null) => throw new NotSupportedException();
public Task<bool> SetModelAsync(Model model, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public ThinkingLevel GetThinkingLevel() => throw new NotSupportedException();
public void SetThinkingLevel(ThinkingLevel level) => throw new NotSupportedException();
public IReadOnlyList<string> GetActiveTools() => throw new NotSupportedException();
public void SetActiveTools(IEnumerable<string> names) => throw new NotSupportedException();
public IReadOnlyList<ToolInfo> GetAllTools() => throw new NotSupportedException();
public IReadOnlyList<CommandInfo> GetCommands() => throw new NotSupportedException();
public string? GetSessionName() => throw new NotSupportedException();
public void SetSessionName(string? name) => throw new NotSupportedException();
public Task<ExecResult> ExecAsync(string command, IReadOnlyList<string> args, ExecOptions? options = null, CancellationToken cancellationToken = default) => throw new NotSupportedException();
private sealed class Subscription : IDisposable { public void Dispose() { } }
}
internal sealed class CalendarRecordingUi : IUserInterface
{
private readonly Lock _lock = new();
private readonly List<(string Message, NotifyKind Kind)> _notes = [];
public IReadOnlyList<(string Message, NotifyKind Kind)> Notes { get { lock (_lock) return _notes.ToList(); } }
public Task<string?> SelectAsync(string title, IReadOnlyList<string> options, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => Task.FromResult<string?>(null);
public Task<bool> ConfirmAsync(string title, string message, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => Task.FromResult(false);
public Task<string?> InputAsync(string title, string? placeholder, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => Task.FromResult<string?>(null);
public void Notify(string message, NotifyKind kind = NotifyKind.Info) { lock (_lock) _notes.Add((message, kind)); }
public void SetStatus(string key, string? text) { }
}
internal static class FlowDriver
{
/// <summary>Moves the fake clock exactly to each poll the flow schedules until the command returns.</summary>
public static async Task CompleteAsync(Task command, GoogleCalendarConnection connection, FakeTimeProvider time)
{
await Eventually.UntilAsync(() => command.IsCompleted || connection.Pending is not null, "the flow to start");
var flow = connection.Pending;
var seen = 0;
while (flow is not null)
{
await Eventually.UntilAsync(() => command.IsCompleted || flow.Parks > seen, "the next poll to be scheduled");
if (command.IsCompleted) break;
seen = flow.Parks;
var due = flow.NextPollAt - time.GetUtcNow();
if (due > TimeSpan.Zero) time.Advance(due);
}
await command.WaitAsync(TimeSpan.FromSeconds(10));
}
/// <summary>Waits until the chat's flow is parked on its first poll delay.</summary>
public static async Task<ConnectFlow> ParkedAsync(GoogleCalendarConnection connection)
{
await Eventually.UntilAsync(() => connection.Pending is { Parks: > 0 }, "the flow to wait for its first poll");
return connection.Pending!;
}
/// <summary>Coarse driving for several flows at once: advance in steps until <paramref name="done"/>.</summary>
public static async Task AdvanceUntilAsync(FakeTimeProvider time, Func<bool> done, TimeSpan step, int timeoutMs = 10000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (!done())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("Timed out driving the flows");
time.Advance(step);
await Task.Delay(5);
}
}
}
/// <summary>The activated extension over fake Google, a fake clock and a temp token root.</summary>
internal sealed class ConnectHarness : IDisposable
{
public static readonly DateTimeOffset Now = new(2026, 9, 26, 8, 0, 0, TimeSpan.Zero);
public const string ScopeA = "telegram-111";
public const string ScopeB = "telegram-222";
public const string DeviceRoute = "POST /device/code";
public const string TokenRoute = "POST /token";
public const string PrimaryRoute = "GET /calendar/v3/calendars/primary";
public static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
private readonly string _root = Directory.CreateTempSubdirectory("gcal-connect-").FullName;
public ConnectHarness(ILogger? logger = null)
{
Http = new GoogleFakeHttp(Time);
Api = new CalendarFakeApi(logger);
Extension = new GoogleCalendarExtension(new GoogleCalendarOptions
{
ClientId = "cid", ClientSecret = "csecret", TokenDirectory = _root,
Http = Http.CreateClient(), Time = Time, Connections = Connections, GetEnv = _ => null,
});
Extension.ActivateAsync(Api, CancellationToken.None).AsTask().GetAwaiter().GetResult();
ScriptDevice("dev-1", "ABCD-EFGH");
Http.Json(PrimaryRoute, """{"id":"me@example.com","timeZone":"Europe/Berlin"}""");
}
public FakeTimeProvider Time { get; } = new(Now);
public GoogleFakeHttp Http { get; }
public GoogleCalendarConnections Connections { get; } = new();
public CalendarFakeApi Api { get; }
public GoogleCalendarExtension Extension { get; }
public string TokenRoot => _root;
public void ScriptDevice(string deviceCode, string userCode, int expiresIn = 1800, int interval = 5) =>
Http.Json(DeviceRoute, $$"""{"device_code":"{{deviceCode}}","user_code":"{{userCode}}","verification_url":"https://www.google.com/device","expires_in":{{expiresIn}},"interval":{{interval}}}""");
public static (System.Net.HttpStatusCode, string) Pending => (System.Net.HttpStatusCode.PreconditionRequired, """{"error":"authorization_pending"}""");
public static (System.Net.HttpStatusCode, string) SlowDown => (System.Net.HttpStatusCode.Forbidden, """{"error":"slow_down"}""");
public static (System.Net.HttpStatusCode, string) Approved(string refreshToken = "rt-1", string accessToken = "at-1") =>
(System.Net.HttpStatusCode.OK, $$"""{"access_token":"{{accessToken}}","expires_in":3599,"refresh_token":"{{refreshToken}}","scope":"https://www.googleapis.com/auth/calendar","token_type":"Bearer"}""");
public GoogleCalendarConnection Connection(string scope = ScopeA) => Extension.ConnectionFor(scope);
public CalendarFakeContext Context(CalendarRecordingUi? ui, string scope = ScopeA) => new(scope, Berlin, ui);
public Task Run(string command, CalendarFakeContext ctx) => Api.Commands[command].Handler("", ctx, CancellationToken.None);
public void Dispose()
{
foreach (var scope in new[] { ScopeA, ScopeB }) Connection(scope).CancelPending();
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
}
}

View File

@@ -0,0 +1,75 @@
using System.Text.Json.Nodes;
using Microsoft.Extensions.Time.Testing;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
/// <summary>A connected chat (refresh token rt-1, access token at-1), the tool, and scripted Google routes.</summary>
internal sealed class CalendarToolHarness : IDisposable
{
public static readonly DateTimeOffset Now = new(2026, 9, 26, 8, 0, 0, TimeSpan.Zero);
public const string Scope = "telegram-111";
public const string EventsRoute = "GET /calendar/v3/calendars/primary/events";
private readonly string _root = Directory.CreateTempSubdirectory("gcal-tool-").FullName;
public CalendarToolHarness(bool connected = true, string? calendarZone = "Europe/Berlin")
{
Http = new GoogleFakeHttp(Time);
Settings = new GoogleCalendarSettings("cid", "csecret", _root, Http.CreateClient(), Time);
Tool = new CalendarTool(Settings, Connections, () => null);
Http.Json("POST /token", """{"access_token":"at-1","expires_in":3600}""");
if (connected) Connection.Store.Write(new StoredToken("rt-1", GoogleOAuthClient.CalendarScope, "me@example.com", calendarZone, Now));
}
public FakeTimeProvider Time { get; } = new(Now);
public GoogleFakeHttp Http { get; }
public GoogleCalendarSettings Settings { get; }
public GoogleCalendarConnections Connections { get; } = new();
public GoogleCalendarConnection Connection => Connections.For(Settings, Scope);
public TimeZoneInfo ChatZone { get; set; } = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
public CalendarTool Tool { get; }
public IReadOnlyList<RecordedRequest> CalendarRequests => Http.Requests.Where(r => r.Uri.Host == "www.googleapis.com").ToList();
public Task<ToolResult> RunAsync(object args)
{
var call = new ToolCallContent("call-1", CalendarTool.ToolName, VolcanicJson.ToElement(args));
return Tool.ExecuteAsync(call, new ToolInvocation("call-1", new CalendarFakeContext(Scope, ChatZone), null), CancellationToken.None);
}
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
}
}
/// <summary>Google event JSON as the Calendar API returns it.</summary>
internal static class Ev
{
public static string Timed(string id, string summary, string start, string end, string? recurringEventId = null,
string[]? recurrence = null, string? location = null)
{
var ev = new JsonObject
{
["id"] = id, ["summary"] = summary, ["htmlLink"] = $"https://calendar.google.com/event?eid={id}",
["start"] = new JsonObject { ["dateTime"] = start, ["timeZone"] = "Europe/Berlin" },
["end"] = new JsonObject { ["dateTime"] = end, ["timeZone"] = "Europe/Berlin" },
};
if (recurringEventId is not null) ev["recurringEventId"] = recurringEventId;
if (recurrence is not null) ev["recurrence"] = new JsonArray(recurrence.Select(r => (JsonNode)r).ToArray());
if (location is not null) ev["location"] = location;
return ev.ToJsonString();
}
public static string AllDay(string id, string summary, string start, string end) =>
new JsonObject { ["id"] = id, ["summary"] = summary, ["start"] = new JsonObject { ["date"] = start }, ["end"] = new JsonObject { ["date"] = end } }.ToJsonString();
public static string List(params string[] items) => $"{{\"items\":[{string.Join(",", items)}]}}";
public static string Page(string? nextPageToken, params string[] items) =>
nextPageToken is null ? List(items) : $"{{\"items\":[{string.Join(",", items)}],\"nextPageToken\":\"{nextPageToken}\"}}";
}

View File

@@ -0,0 +1,14 @@
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
internal static class Eventually
{
public static async Task UntilAsync(Func<bool> condition, string what, int timeoutMs = 5000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException($"Timed out waiting for {what}");
await Task.Delay(5);
}
}
}

View File

@@ -0,0 +1,90 @@
using System.Net;
using System.Text;
using System.Text.Json.Nodes;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
/// <summary>One request as Google would have seen it, with the fake clock's time.</summary>
internal sealed record RecordedRequest(HttpMethod Method, Uri Uri, string? Body, string? Authorization, DateTimeOffset At)
{
public string Route => $"{Method.Method} {Uri.AbsolutePath}";
public IReadOnlyDictionary<string, string> Form => Parse(Body);
public IReadOnlyDictionary<string, string> Query => Parse(Uri.Query.TrimStart('?'));
public JsonObject Json => JsonNode.Parse(Body ?? "{}")!.AsObject();
private static Dictionary<string, string> Parse(string? encoded) =>
(encoded ?? "").Split('&', StringSplitOptions.RemoveEmptyEntries)
.Select(pair => pair.Split('=', 2))
.ToDictionary(p => Decode(p[0]), p => p.Length > 1 ? Decode(p[1]) : "");
private static string Decode(string s) => Uri.UnescapeDataString(s.Replace('+', ' '));
}
/// <summary>
/// Scripted Google endpoints keyed by <c>"METHOD /path"</c> (host ignored). A route serves its responders in order and
/// repeats the last one. Unrouted requests get a 501 so a missing script is loud.
/// </summary>
internal sealed class GoogleFakeHttp(TimeProvider? time = null) : HttpMessageHandler
{
private readonly Lock _lock = new();
private readonly Dictionary<string, (Func<RecordedRequest, Task<HttpResponseMessage>>[] Responders, int Served)> _routes = new();
private readonly List<RecordedRequest> _requests = [];
public IReadOnlyList<RecordedRequest> Requests { get { lock (_lock) return _requests.ToList(); } }
public IReadOnlyList<RecordedRequest> To(string route) { lock (_lock) return _requests.Where(r => r.Route == route).ToList(); }
/// <summary>Replaces the route's script.</summary>
public GoogleFakeHttp On(string route, params Func<RecordedRequest, Task<HttpResponseMessage>>[] responders)
{
lock (_lock) _routes[route] = (responders, 0);
return this;
}
public GoogleFakeHttp Json(string route, params (HttpStatusCode Status, string Body)[] responses) =>
On(route, responses.Select(r => (Func<RecordedRequest, Task<HttpResponseMessage>>)(_ => Task.FromResult(Response(r.Status, r.Body)))).ToArray());
public GoogleFakeHttp Json(string route, string body) => Json(route, (HttpStatusCode.OK, body));
public GoogleFakeHttp Reply(string route, Func<RecordedRequest, (HttpStatusCode Status, string Body)> respond) =>
On(route, r =>
{
var (status, body) = respond(r);
return Task.FromResult(Response(status, body));
});
public static Func<RecordedRequest, Task<HttpResponseMessage>> Throws(string message = "network down") =>
_ => Task.FromException<HttpResponseMessage>(new HttpRequestException(message));
public static Func<RecordedRequest, Task<HttpResponseMessage>> Returns(HttpStatusCode status, string body) =>
_ => Task.FromResult(Response(status, body));
public GoogleFakeHttp Fail(string route, string message = "network down") => On(route, Throws(message));
public static HttpResponseMessage Response(HttpStatusCode status, string body) =>
new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
public HttpClient CreateClient() => new(this, disposeHandler: false);
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var body = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
var recorded = new RecordedRequest(request.Method, request.RequestUri!, body, request.Headers.Authorization?.ToString(),
(time ?? TimeProvider.System).GetUtcNow());
Func<RecordedRequest, Task<HttpResponseMessage>>? responder = null;
lock (_lock)
{
_requests.Add(recorded);
if (_routes.TryGetValue(recorded.Route, out var route))
{
responder = route.Responders[Math.Min(route.Served, route.Responders.Length - 1)];
_routes[recorded.Route] = (route.Responders, route.Served + 1);
}
}
if (responder is null)
return Response(HttpStatusCode.NotImplemented, $$$"""{"error":{"code":501,"message":"no fake route for {{{recorded.Route}}}"}}""");
var response = await responder(recorded);
response.RequestMessage = request;
return response;
}
}

View File

@@ -0,0 +1,16 @@
using Microsoft.Extensions.Logging;
namespace Volcanic.AgenticCore.Extensions.Tests.GoogleCalendar;
internal sealed class ListLogger : ILogger
{
private readonly Lock _lock = new();
private readonly List<(LogLevel Level, string Message)> _entries = [];
public IReadOnlyList<(LogLevel Level, string Message)> Entries { get { lock (_lock) return _entries.ToList(); } }
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
lock (_lock) _entries.Add((logLevel, formatter(state, exception)));
}
}

View File

@@ -10,7 +10,7 @@
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.9.0" />
</ItemGroup>
<ItemGroup>
<None Include="ClaudeCode/Fixtures/*" CopyToOutputDirectory="PreserveNewest" />
@@ -22,6 +22,7 @@
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.OpenAI/Volcanic.AgenticCore.Extensions.OpenAI.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/Volcanic.AgenticCore.Extensions.ClaudeCode.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/Volcanic.AgenticCore.Extensions.GoogleCalendar.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj" />
<ProjectReference Include="../Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer.csproj" />
</ItemGroup>

View File

@@ -0,0 +1,163 @@
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")));
}
}

View File

@@ -28,7 +28,8 @@ public sealed class ChatHost : IAsyncDisposable
private readonly ServiceProvider _services;
private readonly string _root;
public ChatHost(Action<ChatHost, IServiceCollection>? configure = null, Action<AgenticCoreOptions>? core = null, TimeProvider? time = null)
public ChatHost(Action<ChatHost, IServiceCollection>? configure = null, Action<AgenticCoreOptions>? core = null, TimeProvider? time = null,
Volcanic.AgenticCore.Extensions.GoogleCalendar.GoogleCalendarOptions? calendar = null)
{
_root = Path.Combine(Path.GetTempPath(), "volcanic-telegram", Guid.NewGuid().ToString("N"));
Options = new TelegramOptions
@@ -57,7 +58,7 @@ public sealed class ChatHost : IAsyncDisposable
_services = services.BuildServiceProvider();
ILoggerFactory loggers = NullLoggerFactory.Instance;
var sender = new TelegramSender(Bot, NullLogger<TelegramSender>.Instance);
Chats = new ChatRegistry(_services.GetRequiredService<IAgentSessionFactory>(), sender, Options, loggers, time);
Chats = new ChatRegistry(_services.GetRequiredService<IAgentSessionFactory>(), sender, Options, loggers, time, calendar);
Router = new UpdateRouter(Options, Chats, new ChatCommands(sender), sender,
new InboundComposer(sender, NullLogger<InboundComposer>.Instance), NullLogger<UpdateRouter>.Instance);
}