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

278 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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());
}
}