feat(google-calendar): /calendar_status and /calendar_disconnect, per-chat isolation tests
This commit is contained in:
@@ -32,3 +32,50 @@ internal static class ConnectCommand
|
||||
if (flow is not null) await flow.RunAsync(ctx.Ui, ctx.Logger).ConfigureAwait(false);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary><c>/calendar_status</c>.</summary>
|
||||
internal static class StatusCommand
|
||||
{
|
||||
public const string Name = "calendar_status";
|
||||
public const string Description = "Show this chat's Google Calendar connection";
|
||||
|
||||
public static CommandDefinition Create(IExtensionApi api, GoogleCalendarExtension extension) => new(Description, (_, ctx, _) =>
|
||||
{
|
||||
var connection = extension.ConnectionFor(ctx.SessionManager.Scope);
|
||||
CommandReply.Send(api, ctx, Name, Describe(connection));
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
internal static string Describe(GoogleCalendarConnection connection)
|
||||
{
|
||||
if (connection.Pending is { } flow)
|
||||
{
|
||||
var d = flow.Device;
|
||||
return $"Waiting for approval: open {d.VerificationUrl} and enter {d.UserCode} ({ConnectFlow.MinutesLeft(d, connection.Settings.Time)} min left).";
|
||||
}
|
||||
if (connection.Store.Read() is not { } token) return "Not connected. Run /calendar_connect.";
|
||||
var since = token.ConnectedAt?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture) ?? "an unknown date";
|
||||
return $"Connected as {token.Account ?? "an unknown account"} since {since}, time zone {token.TimeZone ?? "unknown"}.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary><c>/calendar_disconnect</c>: cancel a pending flow, revoke (best effort), delete the file, drop the cache.</summary>
|
||||
internal static class DisconnectCommand
|
||||
{
|
||||
public const string Name = "calendar_disconnect";
|
||||
public const string Description = "Disconnect this chat's Google Calendar";
|
||||
|
||||
public static CommandDefinition Create(IExtensionApi api, GoogleCalendarExtension extension) => new(Description, async (_, ctx, ct) =>
|
||||
{
|
||||
var connection = extension.ConnectionFor(ctx.SessionManager.Scope);
|
||||
connection.CancelPending();
|
||||
var token = connection.Store.Read();
|
||||
var revokeError = token is null ? null : await connection.OAuth.RevokeAsync(token.RefreshToken, ct).ConfigureAwait(false);
|
||||
connection.Store.Delete();
|
||||
connection.Invalidate();
|
||||
var text = "Calendar disconnected.";
|
||||
if (revokeError is not null)
|
||||
text += $" Revoking at Google failed: {revokeError}; remove the app at {GoogleCalendarMessages.PermissionsUrl}.";
|
||||
CommandReply.Send(api, ctx, Name, text);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ public sealed class GoogleCalendarExtension(GoogleCalendarOptions? options = nul
|
||||
_settings = _options.Resolve()!;
|
||||
api.RegisterTool(new CalendarTool(_settings, Connections, CurrentZoneName));
|
||||
api.RegisterCommand(ConnectCommand.Name, ConnectCommand.Create(api, this));
|
||||
api.RegisterCommand(StatusCommand.Name, StatusCommand.Create(api, this));
|
||||
api.RegisterCommand(DisconnectCommand.Name, DisconnectCommand.Create(api, this));
|
||||
api.On<SessionStartEvent>((_, ctx, _) => Track(ctx));
|
||||
api.On<BeforeAgentStartEvent>((_, ctx, _) => Track(ctx));
|
||||
return ValueTask.CompletedTask;
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
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_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);
|
||||
}
|
||||
}
|
||||
@@ -36,11 +36,11 @@ public class GoogleCalendarExtensionTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configured_it_registers_the_calendar_tool_and_the_connect_command()
|
||||
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.Contains("calendar_connect", h.Api.Commands.Keys);
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user