forked from admin/VolcanicAgenticCore
- log an unknown stored zone once even when the tool description reads it first - serialise the flow's token write with /calendar_disconnect under the flow gate - a cancelled or expired pending flow is cleared, not re-sent or shown - chat keys anchor with \z so a trailing newline is hashed - a timeMax date includes that whole day (spec updated) - dispose the flow's CancellationTokenSource, drop ConnectFlow.Token, Begin is a plain write - GoogleCalendarOptions.IsConfigured replaces the host's copy of the rule; StoredToken is internal - test BotSetupService's menu through DI, tokens absent from tool results - Telegram.Tests csproj reference placed as in telegram-message-context; ChatHost passes calendar by name - docs: per-event timeZone, both config sources for /calendar_*
278 lines
14 KiB
C#
278 lines
14 KiB
C#
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());
|
||
}
|
||
}
|