forked from admin/VolcanicAgenticCore
feat(google-calendar): create, update and delete with recurring scope resolution
Spec §7: the reference's calendar.ts is not in this repository, so the error and reply texts are now listed in the spec.
This commit is contained in:
@@ -219,11 +219,27 @@ Behaviour follows the reference's recurring-events spec:
|
||||
|
||||
- **Scope resolution** for update/delete: fetch the event once. `recurringEventId` set → `instance`; `recurrence` set
|
||||
→ `series`; neither → single (an explicit `scope` is ignored). An explicit `scope=instance` on a master and
|
||||
`scope=series` on an occurrence are rejected with the reference's error texts. `recurrence` together with an
|
||||
instance is rejected. Recurrence lines must start with `RRULE:`, `EXDATE:`, `RDATE:` or `EXRULE:`; Google is the
|
||||
authority on the rest.
|
||||
`scope=series` on an occurrence are rejected. `recurrence` together with an instance is rejected. Recurrence lines
|
||||
must start with `RRULE:`, `EXDATE:`, `RDATE:` or `EXRULE:` (case-insensitive, trimmed); Google is the authority on
|
||||
the rest. The reference implementation (`VolcanicSecretary/src/tools/calendar.ts`) is not in this repository, so
|
||||
these are the texts (all thrown as `ArgumentException`, which the loop turns into an error tool result):
|
||||
- missing fields: `create needs summary.`, `create needs start.`, `update needs eventId.`, `delete needs eventId.`
|
||||
- any other `scope` value: `scope must be instance or series.`
|
||||
- `scope=instance` on a master: `Event <id> is a recurring series. To change one date, call list_instances with
|
||||
eventId=<id>, then pass that occurrence's id with scope=instance.`
|
||||
- `scope=series` on an occurrence: `Event <id> is one occurrence of the recurring series <series>. To change the
|
||||
whole series, pass eventId=<series> with scope=series.`
|
||||
- `recurrence` on an occurrence: `recurrence can't be set on a single occurrence; update the series
|
||||
(eventId=<series>) instead.`
|
||||
- a bad recurrence line: `Recurrence line "<line>" must start with RRULE:, EXDATE:, RDATE: or EXRULE:.`
|
||||
- an empty patch: `update needs at least one field to change (summary, description, location, start, end or
|
||||
recurrence).`
|
||||
- **update** is a `PATCH` carrying only the fields given (empty patch → error). **delete** of an instance cancels one
|
||||
occurrence; of a master deletes the series. The reply texts name which happened.
|
||||
occurrence; of a master deletes the series. The reply texts name which happened, after the header line:
|
||||
- create: `Created: <line>`
|
||||
- update: `Updated this occurrence only: <line>`, `Updated the whole series: <line>` or `Updated: <line>`
|
||||
- delete: `Cancelled one occurrence of "<summary>" (<range>); the rest of the series is unchanged.`,
|
||||
`Deleted the recurring series "<summary>" and all its occurrences.` or `Deleted "<summary>" (<range>).`
|
||||
- "This and following" edits, attendees, reminders, colours and conferencing are out of scope, as in the reference.
|
||||
- `calendars` lists `calendarList` entries (`id · summary · accessRole · timeZone`, primary marked) so the model can
|
||||
pass `calendarId`.
|
||||
|
||||
@@ -206,7 +206,117 @@ internal sealed partial class CalendarTool(GoogleCalendarSettings settings, Goog
|
||||
|
||||
private static string Require(string? value, string message) => string.IsNullOrWhiteSpace(value) ? throw new ArgumentException(message) : value.Trim();
|
||||
|
||||
/// <summary>create, update, delete: Task 6.</summary>
|
||||
private static Task<ToolResult> WriteAsync(string action, Run run) =>
|
||||
throw new ArgumentException($"{action} is not available yet.");
|
||||
internal enum EventKind { Single, Instance, Series }
|
||||
|
||||
private static readonly string[] RecurrencePrefixes = ["RRULE:", "EXDATE:", "RDATE:", "EXRULE:"];
|
||||
|
||||
private static Task<ToolResult> WriteAsync(string action, Run run) => action switch
|
||||
{
|
||||
"create" => CreateAsync(run),
|
||||
"update" => UpdateAsync(run),
|
||||
_ => DeleteAsync(run),
|
||||
};
|
||||
|
||||
private static async Task<ToolResult> CreateAsync(Run run)
|
||||
{
|
||||
var a = run.Args;
|
||||
var summary = Require(a.Summary, "create needs summary.");
|
||||
var startText = Require(a.Start, "create needs start.");
|
||||
if (a.Recurrence is not null) ValidateRecurrence(a.Recurrence);
|
||||
var (start, end) = CalendarTime.ForCreate(startText, a.End, run.EventZoneName);
|
||||
var body = new JsonObject { ["summary"] = summary, ["start"] = start, ["end"] = end };
|
||||
if (a.Description is not null) body["description"] = a.Description;
|
||||
if (a.Location is not null) body["location"] = a.Location;
|
||||
if (a.Recurrence is { Count: > 0 }) body["recurrence"] = RecurrenceJson(a.Recurrence);
|
||||
var created = await run.Api.SendAsync(HttpMethod.Post, GoogleCalendarApi.Events(run.CalendarId), body, new CalendarCall("create", run.CalendarId), run.Ct).ConfigureAwait(false);
|
||||
return Result("create", $"{run.Header}\nCreated: {Line(created, run.Zone)}", [created]);
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> UpdateAsync(Run run)
|
||||
{
|
||||
var a = run.Args;
|
||||
var id = Require(a.EventId, "update needs eventId.");
|
||||
var call = new CalendarCall("update", run.CalendarId, id);
|
||||
var path = GoogleCalendarApi.Event(run.CalendarId, id);
|
||||
var ev = await run.Api.SendAsync(HttpMethod.Get, path, null, call, run.Ct).ConfigureAwait(false);
|
||||
var kind = ResolveScope(ev, a.Scope, id);
|
||||
|
||||
var patch = new JsonObject();
|
||||
if (a.Summary is not null) patch["summary"] = a.Summary;
|
||||
if (a.Description is not null) patch["description"] = a.Description;
|
||||
if (a.Location is not null) patch["location"] = a.Location;
|
||||
var (start, end) = CalendarTime.ForUpdate(a.Start, a.End, run.EventZoneName);
|
||||
if (start is not null) patch["start"] = start;
|
||||
if (end is not null) patch["end"] = end;
|
||||
if (a.Recurrence is not null)
|
||||
{
|
||||
if (kind == EventKind.Instance)
|
||||
throw new ArgumentException($"recurrence can't be set on a single occurrence; update the series (eventId={Str(ev, "recurringEventId")}) instead.");
|
||||
ValidateRecurrence(a.Recurrence);
|
||||
patch["recurrence"] = RecurrenceJson(a.Recurrence);
|
||||
}
|
||||
if (patch.Count == 0)
|
||||
throw new ArgumentException("update needs at least one field to change (summary, description, location, start, end or recurrence).");
|
||||
|
||||
var updated = await run.Api.SendAsync(HttpMethod.Patch, path, patch, call, run.Ct).ConfigureAwait(false);
|
||||
var what = kind switch
|
||||
{
|
||||
EventKind.Instance => "Updated this occurrence only",
|
||||
EventKind.Series => "Updated the whole series",
|
||||
_ => "Updated",
|
||||
};
|
||||
return Result("update", $"{run.Header}\n{what}: {Line(updated, run.Zone)}", [updated]);
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> DeleteAsync(Run run)
|
||||
{
|
||||
var id = Require(run.Args.EventId, "delete needs eventId.");
|
||||
var call = new CalendarCall("delete", run.CalendarId, id);
|
||||
var path = GoogleCalendarApi.Event(run.CalendarId, id);
|
||||
var ev = await run.Api.SendAsync(HttpMethod.Get, path, null, call, run.Ct).ConfigureAwait(false);
|
||||
var kind = ResolveScope(ev, run.Args.Scope, id);
|
||||
await run.Api.SendAsync(HttpMethod.Delete, path, null, call, run.Ct).ConfigureAwait(false);
|
||||
var summary = Str(ev, "summary") is { Length: > 0 } s ? s : "(no title)";
|
||||
var range = ev.TryGetProperty("start", out var start) ? CalendarTime.FormatRange(start, ev.TryGetProperty("end", out var end) ? end : start, run.Zone) : "";
|
||||
var text = kind switch
|
||||
{
|
||||
EventKind.Instance => $"Cancelled one occurrence of \"{summary}\" ({range}); the rest of the series is unchanged.",
|
||||
EventKind.Series => $"Deleted the recurring series \"{summary}\" and all its occurrences.",
|
||||
_ => $"Deleted \"{summary}\" ({range}).",
|
||||
};
|
||||
return Result("delete", $"{run.Header}\n{text}", [ev]);
|
||||
}
|
||||
|
||||
/// <summary><c>recurringEventId</c> → instance; <c>recurrence</c> → series; neither → single (explicit scope ignored).</summary>
|
||||
internal static EventKind ResolveScope(JsonElement ev, string? requested, string eventId)
|
||||
{
|
||||
var scope = requested?.Trim().ToLowerInvariant();
|
||||
if (scope is not (null or "" or "instance" or "series")) throw new ArgumentException("scope must be instance or series.");
|
||||
if (Str(ev, "recurringEventId") is { Length: > 0 } series)
|
||||
{
|
||||
if (scope == "series")
|
||||
throw new ArgumentException($"Event {eventId} is one occurrence of the recurring series {series}. To change the whole series, pass eventId={series} with scope=series.");
|
||||
return EventKind.Instance;
|
||||
}
|
||||
if (Recurrence(ev) is { Count: > 0 })
|
||||
{
|
||||
if (scope == "instance")
|
||||
throw new ArgumentException($"Event {eventId} is a recurring series. To change one date, call list_instances with eventId={eventId}, then pass that occurrence's id with scope=instance.");
|
||||
return EventKind.Series;
|
||||
}
|
||||
return EventKind.Single;
|
||||
}
|
||||
|
||||
/// <summary>Every line must be an RFC 5545 RRULE, EXDATE, RDATE or EXRULE property.</summary>
|
||||
internal static void ValidateRecurrence(IReadOnlyList<string> lines)
|
||||
{
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var trimmed = (line ?? "").Trim();
|
||||
if (!RecurrencePrefixes.Any(p => trimmed.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new ArgumentException($"Recurrence line \"{line}\" must start with RRULE:, EXDATE:, RDATE: or EXRULE:.");
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonArray RecurrenceJson(IReadOnlyList<string> lines) => new(lines.Select(r => (JsonNode)r.Trim()).ToArray());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user