forked from admin/VolcanicAgenticCore
Merge branch 'feat/google-calendar'
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) { }
|
||||
}
|
||||
}
|
||||
@@ -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}\"}}";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user