From 363d20ef7587eb4916ddcd98e01a1eed77076f0b Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 22 Sep 2026 00:37:14 +0000 Subject: [PATCH 1/6] feat(events): support single-activity rolls and persist automatic winners --- AGENTS.md | 18 ++ .../Functions/EventFunctions.cs | 3 + .../Properties/AssemblyInfo.cs | 3 + .../Entities/EventEntity.cs | 9 + .../MW-GC.EventManager.Tests.csproj | 2 + .../SingleActivityApiTests.cs | 154 ++++++++++++++++++ MW-GC.EventManager.Tests/SlotRerollTests.cs | 64 ++++++++ MW-GC.EventManager.Web/Pages/Events.razor | 7 +- docs/agents/domain.md | 5 + docs/agents/issue-tracker.md | 15 ++ docs/agents/triage-labels.md | 11 ++ 11 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 AGENTS.md create mode 100644 MW-GC.EventManager.API/Properties/AssemblyInfo.cs create mode 100644 MW-GC.EventManager.Tests/SingleActivityApiTests.cs create mode 100644 docs/agents/domain.md create mode 100644 docs/agents/issue-tracker.md create mode 100644 docs/agents/triage-labels.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..06ee368 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,18 @@ +# EventManager + +.NET 10 solution: Blazor WebAssembly UI, Azure Functions API, shared entities, and xUnit tests. +Run `dotnet test MW-GC.EventManager.Tests/MW-GC.EventManager.Tests.csproj` and `dotnet build MW-GC.EventManager.slnx` before submitting changes. Use conventional commits and target pull requests at `dev`; do not merge or deploy without authorization. + +## Agent skills + +### Issue tracker + +GitHub Issues in MW-GC/EventManager. See `docs/agents/issue-tracker.md`. + +### Triage labels + +Use the default five triage roles. See `docs/agents/triage-labels.md`. + +### Domain docs + +Single-context layout. See `docs/agents/domain.md`. diff --git a/MW-GC.EventManager.API/Functions/EventFunctions.cs b/MW-GC.EventManager.API/Functions/EventFunctions.cs index e198f5a..16de969 100644 --- a/MW-GC.EventManager.API/Functions/EventFunctions.cs +++ b/MW-GC.EventManager.API/Functions/EventFunctions.cs @@ -83,6 +83,7 @@ public async Task Generate( UniqueGamesOnly = request.UniqueGamesOnly }; + entity.NormalizeWinner(); await _store.UpsertAsync(entity, ct); return new CreatedResult($"/api/events/{entity.Id}", entity); } @@ -96,6 +97,7 @@ public async Task SaveCustomized( if (entity is null) return new BadRequestResult(); entity.Id = Guid.NewGuid(); + entity.NormalizeWinner(); await _store.UpsertAsync(entity, ct); return new CreatedResult($"/api/events/{entity.Id}", entity); } @@ -112,6 +114,7 @@ public async Task Update( if (entity is null) return new BadRequestResult(); entity.Id = id; + entity.NormalizeWinner(); await _store.UpsertAsync(entity, ct); return new OkObjectResult(entity); } diff --git a/MW-GC.EventManager.API/Properties/AssemblyInfo.cs b/MW-GC.EventManager.API/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..1c63fb1 --- /dev/null +++ b/MW-GC.EventManager.API/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("MW-GC.EventManager.Tests")] diff --git a/MW-GC.EventManager.Shared/Entities/EventEntity.cs b/MW-GC.EventManager.Shared/Entities/EventEntity.cs index 877af2f..20040db 100644 --- a/MW-GC.EventManager.Shared/Entities/EventEntity.cs +++ b/MW-GC.EventManager.Shared/Entities/EventEntity.cs @@ -15,4 +15,13 @@ public class EventEntity : EntityBase /// public Guid? WinnerActivityId { get; set; } public bool UniqueGamesOnly { get; set; } = true; + + /// Apply winner rules to the final selections before saving. + public void NormalizeWinner() + { + if (Selections.Count == 1) + WinnerActivityId = Selections[0].Activity.Id; + else if (WinnerActivityId.HasValue && !Selections.Any(s => s.Activity.Id == WinnerActivityId.Value)) + WinnerActivityId = null; + } } diff --git a/MW-GC.EventManager.Tests/MW-GC.EventManager.Tests.csproj b/MW-GC.EventManager.Tests/MW-GC.EventManager.Tests.csproj index 80d0f2a..383eb2b 100644 --- a/MW-GC.EventManager.Tests/MW-GC.EventManager.Tests.csproj +++ b/MW-GC.EventManager.Tests/MW-GC.EventManager.Tests.csproj @@ -11,5 +11,7 @@ + + diff --git a/MW-GC.EventManager.Tests/SingleActivityApiTests.cs b/MW-GC.EventManager.Tests/SingleActivityApiTests.cs new file mode 100644 index 0000000..ce9c0db --- /dev/null +++ b/MW-GC.EventManager.Tests/SingleActivityApiTests.cs @@ -0,0 +1,154 @@ +using System.Linq.Expressions; +using System.Text.Json; +using Azure; +using Azure.Data.Tables; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using MW_GC.EventManager.API.Functions; +using MW_GC.EventManager.API.Services; +using MW_GC.EventManager.Shared.Entities; +using MW_GC.EventManager.Shared.Models; +using MW_GC.EventManager.Shared.Requests; +using Xunit; + +namespace MW_GC.EventManager.Tests; + +public class SingleActivityApiTests +{ + private readonly Dictionary rows = []; + private readonly EventFunctions functions; + private readonly Game game = new() { Id = Guid.NewGuid(), Name = "Game" }; + private readonly Activity activity; + + public SingleActivityApiTests() + { + activity = new Activity { Id = Guid.NewGuid(), GameId = game.Id, Name = "Activity" }; + var table = new Mock(); + table.Setup(t => t.UpsertEntityAsync(It.IsAny(), TableUpdateMode.Replace, It.IsAny())) + .Callback((row, _, _) => rows[row.RowKey] = new TableEntity(row)) + .ReturnsAsync(Mock.Of()); + table.Setup(t => t.GetEntityAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync((string _, string id, IEnumerable _, CancellationToken _) => Response.FromValue(rows[id], Mock.Of())); + var service = new Mock(); + service.Setup(s => s.GetTableClient(It.IsAny())).Returns(table.Object); + var games = new Mock(); + games.Setup(t => t.QueryAsync(It.IsAny>>(), It.IsAny(), It.IsAny>(), It.IsAny())) + .Returns(AsyncPageable.FromPages([Page.FromValues([new GameEntity { Id = game.Id }], null, Mock.Of())])); + var activities = new Mock(); + activities.Setup(t => t.QueryAsync(It.IsAny>>(), It.IsAny(), It.IsAny>(), It.IsAny())) + .Returns(AsyncPageable.FromPages([Page.FromValues([new TableEntity("activities", activity.Id.ToString()) { ["GameId"] = game.Id }], null, Mock.Of())])); + service.Setup(s => s.GetTableClient("games")).Returns(games.Object); + service.Setup(s => s.GetTableClient("activities")).Returns(activities.Object); + functions = new EventFunctions(new(service.Object, "events", "events"), new(service.Object, "games", "games"), new(service.Object, "activities", "activities"), new()); + } + + private EventEntity Event(params Activity[] activities) => new() + { + Name = "Single roll", Date = DateTimeOffset.UtcNow, + Selections = activities.Select(a => new Selection { Game = game, Activity = a }).ToList() + }; + + private static HttpRequest Request(T value) + { + var context = new DefaultHttpContext(); + context.RequestServices = new ServiceCollection().AddOptions().BuildServiceProvider(); + context.Request.ContentType = "application/json"; + context.Request.Body = new MemoryStream(JsonSerializer.SerializeToUtf8Bytes(value)); + return context.Request; + } + + private async Task Read(Guid id) => Assert.IsType(Assert.IsType(await functions.Get(Request(new { }), id, default)).Value); + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task GenerateOnePersistsWinner(bool unique) + { + var result = Assert.IsType(await functions.Generate(Request(new GenerateEventRequest { Count = 1, UniqueGamesOnly = unique }), default)); + var saved = Assert.IsType(result.Value); + Assert.Equal(activity.Id, saved.WinnerActivityId); + Assert.Equal(activity.Id, (await Read(saved.Id)).WinnerActivityId); + Assert.Single((await Read(saved.Id)).Selections); + } + + [Fact] + public async Task CustomizedSaveOverridesStaleWinnerAndRoundTripsStorage() + { + var input = Event(activity); + input.WinnerActivityId = Guid.NewGuid(); + var saved = Assert.IsType(Assert.IsType(await functions.SaveCustomized(Request(input), default)).Value); + Assert.NotEqual(Guid.Empty, saved.Id); + Assert.Equal(activity.Id, rows[saved.Id.ToString()]["WinnerActivityId"]); + var read = await Read(saved.Id); + Assert.Equal(activity.Id, read.WinnerActivityId); + Assert.Equal(activity.Id, Assert.Single(read.Selections).Activity.Id); + } + + [Fact] + public async Task EditingReplacesSingleWinnerPreservesValidMultiWinnerAndClearsRemovedWinner() + { + var saved = Assert.IsType(Assert.IsType(await functions.SaveCustomized(Request(Event(activity)), default)).Value); + var replacement = new Activity { Id = Guid.NewGuid(), GameId = game.Id, Name = "Other" }; + saved.Selections = Event(replacement).Selections; + await functions.Update(Request(saved), saved.Id, default); + saved = await Read(saved.Id); + Assert.Equal(replacement.Id, saved.WinnerActivityId); + saved.Selections.AddRange(Event(activity).Selections); + await functions.Update(Request(saved), saved.Id, default); + Assert.Equal(replacement.Id, (await Read(saved.Id)).WinnerActivityId); + saved.Selections.RemoveAt(0); + saved.Selections.AddRange(Event(new Activity { Id = Guid.NewGuid(), GameId = game.Id, Name = "Other" }).Selections); + await functions.Update(Request(saved), saved.Id, default); + Assert.Null((await Read(saved.Id)).WinnerActivityId); + } + + [Fact] + public async Task MultipleSelectionsDoNotAutomaticallyChooseWinner() + { + var input = Event(activity, new Activity { Id = Guid.NewGuid(), GameId = game.Id, Name = "Other" }); + var saved = Assert.IsType(Assert.IsType(await functions.SaveCustomized(Request(input), default)).Value); + Assert.Null((await Read(saved.Id)).WinnerActivityId); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void OneSlotRespectsAllFiltersAndEmptyPools(bool unique) + { + var theme = Guid.NewGuid(); + var holiday = Guid.NewGuid(); + activity.ThemeIds.Add(theme); + activity.HolidayIds.Add(holiday); + var request = new GenerateEventRequest { Count = 1, UniqueGamesOnly = unique, ThemedOnly = true, SelectedGameIds = [game.Id], SelectedThemeIds = [theme], SelectedHolidayIds = [holiday] }; + var generator = new EventGenerator(); + Assert.Equal(activity.Id, Assert.Single(generator.Generate([game], [activity], request)!).Activity.Id); + request.SelectedGameIds[0] = Guid.NewGuid(); + Assert.Null(generator.Generate([game], [activity], request)); + request.SelectedGameIds[0] = game.Id; + request.SelectedThemeIds[0] = Guid.NewGuid(); + Assert.Null(generator.Generate([game], [activity], request)); + request.SelectedThemeIds[0] = theme; + request.SelectedHolidayIds[0] = Guid.NewGuid(); + Assert.Null(generator.Generate([game], [activity], request)); + request.SelectedHolidayIds.Clear(); + request.SelectedThemeIds.Clear(); + activity.ThemeIds.Clear(); + activity.HolidayIds.Clear(); + Assert.Null(generator.Generate([game], [activity], request)); + } + + [Fact] + public void MultiSlotUniqueGamesAndDuplicateActivityConstraintsRemain() + { + var other = new Activity { Id = Guid.NewGuid(), GameId = game.Id, Name = "Other" }; + var generator = new EventGenerator(); + var request = new GenerateEventRequest { Count = 2, UniqueGamesOnly = true }; + Assert.Null(generator.Generate([game], [activity, other], request)); + request = new GenerateEventRequest { Count = 2, UniqueGamesOnly = false }; + var selections = generator.Generate([game], [activity, other], request)!; + Assert.Equal(2, selections.Select(s => s.Activity.Id).Distinct().Count()); + Assert.Null(generator.Generate([game], [activity], request)); + } +} diff --git a/MW-GC.EventManager.Tests/SlotRerollTests.cs b/MW-GC.EventManager.Tests/SlotRerollTests.cs index 3daa75a..ce599fe 100644 --- a/MW-GC.EventManager.Tests/SlotRerollTests.cs +++ b/MW-GC.EventManager.Tests/SlotRerollTests.cs @@ -146,6 +146,70 @@ public void RepeatedRollsKeepOtherSlotsAndStayInEligiblePool() } private static ActivityEntity Activity(Guid game) => new() { Id = Guid.NewGuid(), GameId = game }; + + [Fact] + public void RemoveAllowsOneSlotButNeverRemovesLastSlot() + { + Call("RemoveSlot", 1); + Assert.Single(Slots.Cast()); + Call("RemoveSlot", 0); + Assert.Single(Slots.Cast()); + Assert.Equal(current.Id, Id(Slots[0]!, "ActivityId")); + } + + [Fact] + public async Task OneSlotCreateAndEditSendAutomaticWinnerAndReload() + { + Call("RemoveSlot", 1); + Set("_custName", "One activity"); + var handler = new EventHttpHandler(); + var service = new MW_GC.EventManager.Web.Services.EventService(new HttpClient(handler) { BaseAddress = new Uri("https://example.test") }); + typeof(Events).GetProperty("EventSvc", Private)!.SetValue(page, service); + await (Task)typeof(Events).GetMethod("SaveCustomizedEvent", Private)!.Invoke(page, null)!; + Assert.Equal(HttpMethod.Post, handler.LastWrite); + Assert.Equal(current.Id, handler.Saved!.WinnerActivityId); + Assert.Equal(current.Id, Assert.Single((List)Get("_events")!).WinnerActivityId); + + typeof(Events).GetMethod("ShowEditEvent", Private)!.Invoke(page, [handler.Saved]); + Assert.Single(Slots.Cast()); + Call("RerollSlot", 0); + var replacementId = Id(Slots[0]!, "ActivityId"); + await (Task)typeof(Events).GetMethod("SaveCustomizedEvent", Private)!.Invoke(page, null)!; + Assert.Equal(HttpMethod.Put, handler.LastWrite); + Assert.Equal(replacementId, handler.Saved!.WinnerActivityId); + Assert.Equal("One activity", handler.Saved.Name); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void OneSlotRandomizationAndRerollRespectFilters(bool unique) + { + Call("RemoveSlot", 1); + Set("_custUniqueGames", unique); + Set("_custGameIds", new List { gameA }); + Set("_custThemeIds", new List { theme }); + Set("_custHolidayIds", new List { holiday }); + Set("_custThemedOnly", true); + typeof(Events).GetMethod("RandomizeAll", Private)!.Invoke(page, null); + Assert.Single(Slots.Cast()); + Assert.Equal(replacement.Id, Id(Slots[0]!, "ActivityId")); + Assert.Empty(Candidates()); + } + + private sealed class EventHttpHandler : HttpMessageHandler + { + public EventEntity? Saved { get; private set; } + public HttpMethod? LastWrite { get; private set; } + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.Method == HttpMethod.Get) + return new(System.Net.HttpStatusCode.OK) { Content = System.Net.Http.Json.JsonContent.Create(new[] { Saved! }) }; + LastWrite = request.Method; + Saved = System.Text.Json.JsonSerializer.Deserialize(await request.Content!.ReadAsStringAsync(cancellationToken), new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)); + return new(System.Net.HttpStatusCode.OK) { Content = System.Net.Http.Json.JsonContent.Create(Saved) }; + } + } private IList Slots => (IList)Get("_custSelections")!; private List Activities => (List)Get("_activities")!; private List Candidates() => (List)Call("GetRerollCandidates", 0)!; diff --git a/MW-GC.EventManager.Web/Pages/Events.razor b/MW-GC.EventManager.Web/Pages/Events.razor index 151d138..ab8674f 100644 --- a/MW-GC.EventManager.Web/Pages/Events.razor +++ b/MW-GC.EventManager.Web/Pages/Events.razor @@ -168,6 +168,7 @@ Wrap="true">
Game & Activity Selections (@_custSelections.Count) + Select 1–5 activities. If only one activity is selected when saved, it automatically becomes the winner. @if (_custThemedOnly || _custGameIds.Count > 0 || _custThemeIds.Count > 0 || _custHolidayIds.Count > 0) { Filters apply when randomizing all selections or re-rolling a slot @@ -239,7 +240,7 @@ @@ -612,7 +613,7 @@ private void RemoveSlot(int index) { - if (_custSelections.Count <= 2) return; + if (_custSelections.Count <= 1) return; _custSelections.RemoveAt(index); } @@ -659,6 +660,8 @@ WinnerActivityId = _editingEvent?.WinnerActivityId }; + entity.NormalizeWinner(); + if (_editingEvent is not null) await EventSvc.UpdateAsync(entity); else diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 0000000..85360fa --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,5 @@ +# Domain Docs + +This is a single-context repository: `CONTEXT.md` and `docs/adr/` live at the root. +Before exploring, read `CONTEXT.md` and relevant ADRs when present. If absent, proceed silently; domain modeling creates them lazily when terms or decisions are resolved. +Use the glossary's vocabulary in code, issues, and proposals. Flag conflicts with an existing ADR explicitly rather than silently overriding it. diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 0000000..d3825ee --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,15 @@ +# Issue tracker: GitHub + +Issues and specs live in MW-GC/EventManager GitHub Issues. Infer the repository from `git remote -v`. +Use `gh issue view --comments`, `gh issue list`, `gh issue create`, `gh issue comment`, and `gh issue edit` for issue operations. If `gh` is unavailable, use the corresponding GitHub REST API endpoints; never log credentials. +Publishing to the issue tracker means creating a GitHub issue; fetching a ticket includes its body, labels, and comments. + +## Pull requests as a triage surface + +**PRs as a request surface: no.** + +Target implementation PRs at `dev`. Verify the remote PR base and head SHA after creation. Do not merge or deploy without authorization. + +## Wayfinding + +Use a `wayfinder:map` issue with linked child issues (`wayfinder:`). Prefer GitHub native sub-issues and issue dependencies; fall back to parent task lists, `Part of #`, and `Blocked by: #` when unsupported. Claim eligible, unassigned, unblocked tickets before working; publish resolution evidence back to the tracker. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 0000000..6493b1b --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,11 @@ +# Triage Labels + +| Canonical role | Tracker label | Meaning | +| --- | --- | --- | +| needs-triage | needs-triage | Maintainer evaluation needed | +| needs-info | needs-info | Waiting on reporter | +| ready-for-agent | ready-for-agent | Fully specified, ready for an agent | +| ready-for-human | ready-for-human | Requires human implementation | +| wontfix | wontfix | Will not be actioned | + +Use these exact tracker labels when a skill names a role. This mapping does not assert that labels have already been created remotely. From fbb2f6ccb906159e6a51756ab4b3745ef163c09a Mon Sep 17 00:00:00 2001 From: "MWG Bender[bot]" <331880973+mwg-bender[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 01:28:40 +0000 Subject: [PATCH 2/6] fix(events): validate selections and preserve failed-save state Enforce one-to-five valid selections, normalize winners across edits, and retain dialog state on failed saves. Cover API persistence and UI save and reroll regressions for issue #27. --- .../Functions/EventFunctions.cs | 27 ++- .../Services/EventGenerator.cs | 4 + .../Entities/EventEntity.cs | 18 ++ .../SingleActivityApiTests.cs | 221 +++++++++++++++++- MW-GC.EventManager.Tests/SlotRerollTests.cs | 189 +++++++++++++++ MW-GC.EventManager.Web/Pages/Events.razor | 67 +++++- 6 files changed, 507 insertions(+), 19 deletions(-) diff --git a/MW-GC.EventManager.API/Functions/EventFunctions.cs b/MW-GC.EventManager.API/Functions/EventFunctions.cs index 16de969..fa7607c 100644 --- a/MW-GC.EventManager.API/Functions/EventFunctions.cs +++ b/MW-GC.EventManager.API/Functions/EventFunctions.cs @@ -50,10 +50,13 @@ public async Task Generate( [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "events/generate")] HttpRequest req, CancellationToken ct) { - var request = await req.ReadFromJsonAsync(ct) ?? new(); + var request = await ReadBody(req, ct); + if (request is null) return new BadRequestResult(); - if (request.Count < 1) - return new BadRequestObjectResult("Count must be at least 1."); + if (request.Count is < 1 or > EventEntity.MaximumSelections) + return new BadRequestObjectResult($"Count must be between 1 and {EventEntity.MaximumSelections}."); + if (request.SelectedGameIds is null || request.SelectedThemeIds is null || request.SelectedHolidayIds is null) + return new BadRequestObjectResult("Filter lists must not be null."); var gameEntities = await _games.GetAllAsync(ct); var activityEntities = await _activities.GetAllAsync(ct); @@ -93,8 +96,9 @@ public async Task SaveCustomized( [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "events")] HttpRequest req, CancellationToken ct) { - var entity = await req.ReadFromJsonAsync(ct); + var entity = await ReadBody(req, ct); if (entity is null) return new BadRequestResult(); + if (entity.ValidateSelections() is { } error) return new BadRequestObjectResult(error); entity.Id = Guid.NewGuid(); entity.NormalizeWinner(); @@ -110,8 +114,9 @@ public async Task Update( var existing = await _store.GetAsync(id, ct); if (existing is null) return new NotFoundResult(); - var entity = await req.ReadFromJsonAsync(ct); + var entity = await ReadBody(req, ct); if (entity is null) return new BadRequestResult(); + if (entity.ValidateSelections() is { } error) return new BadRequestObjectResult(error); entity.Id = id; entity.NormalizeWinner(); @@ -119,6 +124,18 @@ public async Task Update( return new OkObjectResult(entity); } + private static async Task ReadBody(HttpRequest request, CancellationToken ct) where T : class + { + try + { + return await request.ReadFromJsonAsync(ct); + } + catch (System.Text.Json.JsonException) + { + return null; + } + } + [Function("DeleteEvent")] public async Task Delete( [HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "events/{id:guid}")] HttpRequest req, diff --git a/MW-GC.EventManager.API/Services/EventGenerator.cs b/MW-GC.EventManager.API/Services/EventGenerator.cs index 9b939ff..86ea64c 100644 --- a/MW-GC.EventManager.API/Services/EventGenerator.cs +++ b/MW-GC.EventManager.API/Services/EventGenerator.cs @@ -16,6 +16,10 @@ internal sealed class EventGenerator IReadOnlyList activities, GenerateEventRequest request) { + if (request.Count is < 1 or > MW_GC.EventManager.Shared.Entities.EventEntity.MaximumSelections + || request.SelectedGameIds is null || request.SelectedThemeIds is null || request.SelectedHolidayIds is null) + return null; + var filtered = FilterActivities(activities, request); var gamesWithActivities = games diff --git a/MW-GC.EventManager.Shared/Entities/EventEntity.cs b/MW-GC.EventManager.Shared/Entities/EventEntity.cs index 20040db..3d99ab7 100644 --- a/MW-GC.EventManager.Shared/Entities/EventEntity.cs +++ b/MW-GC.EventManager.Shared/Entities/EventEntity.cs @@ -16,6 +16,24 @@ public class EventEntity : EntityBase public Guid? WinnerActivityId { get; set; } public bool UniqueGamesOnly { get; set; } = true; + public const int MaximumSelections = 5; + + /// Validate selection snapshots before normalizing or persisting an event. + public string? ValidateSelections() + { + if (Selections is null || Selections.Count is < 1 or > MaximumSelections) + return $"Select between 1 and {MaximumSelections} activities."; + if (Selections.Any(s => s is null || s.Game is null || s.Activity is null + || s.Game.Id == Guid.Empty || s.Activity.Id == Guid.Empty + || s.Activity.GameId != s.Game.Id)) + return "Each selection must contain an activity and its matching game."; + if (Selections.Select(s => s.Activity.Id).Distinct().Count() != Selections.Count) + return "An activity may only be selected once."; + if (UniqueGamesOnly && Selections.Select(s => s.Game.Id).Distinct().Count() != Selections.Count) + return "Each selection must use a different game when unique games are required."; + return null; + } + /// Apply winner rules to the final selections before saving. public void NormalizeWinner() { diff --git a/MW-GC.EventManager.Tests/SingleActivityApiTests.cs b/MW-GC.EventManager.Tests/SingleActivityApiTests.cs index ce9c0db..4834ddb 100644 --- a/MW-GC.EventManager.Tests/SingleActivityApiTests.cs +++ b/MW-GC.EventManager.Tests/SingleActivityApiTests.cs @@ -18,6 +18,8 @@ namespace MW_GC.EventManager.Tests; public class SingleActivityApiTests { private readonly Dictionary rows = []; + private readonly List gameRows = []; + private readonly List activityRows = []; private readonly EventFunctions functions; private readonly Game game = new() { Id = Guid.NewGuid(), Name = "Game" }; private readonly Activity activity; @@ -31,14 +33,18 @@ public SingleActivityApiTests() .ReturnsAsync(Mock.Of()); table.Setup(t => t.GetEntityAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync((string _, string id, IEnumerable _, CancellationToken _) => Response.FromValue(rows[id], Mock.Of())); + table.Setup(t => t.QueryAsync(It.IsAny>>(), It.IsAny(), It.IsAny>(), It.IsAny())) + .Returns(() => AsyncPageable.FromPages([Page.FromValues(rows.Values.ToList(), null, Mock.Of())])); var service = new Mock(); service.Setup(s => s.GetTableClient(It.IsAny())).Returns(table.Object); var games = new Mock(); + gameRows.Add(new GameEntity { Id = game.Id }); games.Setup(t => t.QueryAsync(It.IsAny>>(), It.IsAny(), It.IsAny>(), It.IsAny())) - .Returns(AsyncPageable.FromPages([Page.FromValues([new GameEntity { Id = game.Id }], null, Mock.Of())])); + .Returns(() => AsyncPageable.FromPages([Page.FromValues(gameRows, null, Mock.Of())])); var activities = new Mock(); + activityRows.Add(new TableEntity("activities", activity.Id.ToString()) { ["GameId"] = game.Id }); activities.Setup(t => t.QueryAsync(It.IsAny>>(), It.IsAny(), It.IsAny>(), It.IsAny())) - .Returns(AsyncPageable.FromPages([Page.FromValues([new TableEntity("activities", activity.Id.ToString()) { ["GameId"] = game.Id }], null, Mock.Of())])); + .Returns(() => AsyncPageable.FromPages([Page.FromValues(activityRows, null, Mock.Of())])); service.Setup(s => s.GetTableClient("games")).Returns(games.Object); service.Setup(s => s.GetTableClient("activities")).Returns(activities.Object); functions = new EventFunctions(new(service.Object, "events", "events"), new(service.Object, "games", "games"), new(service.Object, "activities", "activities"), new()); @@ -46,7 +52,7 @@ public SingleActivityApiTests() private EventEntity Event(params Activity[] activities) => new() { - Name = "Single roll", Date = DateTimeOffset.UtcNow, + Name = "Single roll", Date = new DateTimeOffset(2026, 9, 21, 18, 0, 0, TimeSpan.Zero), UniqueGamesOnly = false, Selections = activities.Select(a => new Selection { Game = game, Activity = a }).ToList() }; @@ -61,6 +67,32 @@ private static HttpRequest Request(T value) private async Task Read(Guid id) => Assert.IsType(Assert.IsType(await functions.Get(Request(new { }), id, default)).Value); + private async Task AssertPersisted(EventEntity expected) + { + var row = rows[expected.Id.ToString()]; + Assert.Equal(expected.WinnerActivityId, row.TryGetValue("WinnerActivityId", out var winner) ? winner : null); + var detail = await Read(expected.Id); + var listed = Assert.IsType>(Assert.IsType(await functions.GetAll(Request(new { }), default)).Value); + foreach (var actual in new[] { detail, Assert.Single(listed) }) + { + Assert.Equal(expected.Id, actual.Id); + Assert.Equal(expected.Name, actual.Name); + Assert.Equal(expected.Date, actual.Date); + Assert.Equal(expected.UniqueGamesOnly, actual.UniqueGamesOnly); + Assert.Equal(expected.Selections.Select(s => s.Activity.Id), actual.Selections.Select(s => s.Activity.Id)); + Assert.Equal(expected.WinnerActivityId, actual.WinnerActivityId); + } + } + + private async Task Update(EventEntity input) + { + // A rejected edit must fail here, not masquerade as a winner-storage regression. + var result = Assert.IsType(await functions.Update(Request(input), input.Id, default)); + var updated = Assert.IsType(result.Value); + await AssertPersisted(updated); + return updated; + } + [Theory] [InlineData(true)] [InlineData(false)] @@ -68,17 +100,27 @@ public async Task GenerateOnePersistsWinner(bool unique) { var result = Assert.IsType(await functions.Generate(Request(new GenerateEventRequest { Count = 1, UniqueGamesOnly = unique }), default)); var saved = Assert.IsType(result.Value); + Assert.Single(saved.Selections); + Assert.Equal(unique, saved.UniqueGamesOnly); Assert.Equal(activity.Id, saved.WinnerActivityId); + await AssertPersisted(saved); Assert.Equal(activity.Id, (await Read(saved.Id)).WinnerActivityId); Assert.Single((await Read(saved.Id)).Selections); + var listed = Assert.IsType>(Assert.IsType(await functions.GetAll(Request(new { }), default)).Value); + Assert.Equal(activity.Id, Assert.Single(listed).WinnerActivityId); } - [Fact] - public async Task CustomizedSaveOverridesStaleWinnerAndRoundTripsStorage() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CustomizedSaveOverridesStaleWinnerAndRoundTripsStorage(bool unique) { var input = Event(activity); + input.UniqueGamesOnly = unique; input.WinnerActivityId = Guid.NewGuid(); var saved = Assert.IsType(Assert.IsType(await functions.SaveCustomized(Request(input), default)).Value); + Assert.Equal(activity.Id, saved.WinnerActivityId); + await AssertPersisted(saved); Assert.NotEqual(Guid.Empty, saved.Id); Assert.Equal(activity.Id, rows[saved.Id.ToString()]["WinnerActivityId"]); var read = await Read(saved.Id); @@ -92,15 +134,15 @@ public async Task EditingReplacesSingleWinnerPreservesValidMultiWinnerAndClearsR var saved = Assert.IsType(Assert.IsType(await functions.SaveCustomized(Request(Event(activity)), default)).Value); var replacement = new Activity { Id = Guid.NewGuid(), GameId = game.Id, Name = "Other" }; saved.Selections = Event(replacement).Selections; - await functions.Update(Request(saved), saved.Id, default); + saved = await Update(saved); saved = await Read(saved.Id); Assert.Equal(replacement.Id, saved.WinnerActivityId); saved.Selections.AddRange(Event(activity).Selections); - await functions.Update(Request(saved), saved.Id, default); + saved = await Update(saved); Assert.Equal(replacement.Id, (await Read(saved.Id)).WinnerActivityId); saved.Selections.RemoveAt(0); saved.Selections.AddRange(Event(new Activity { Id = Guid.NewGuid(), GameId = game.Id, Name = "Other" }).Selections); - await functions.Update(Request(saved), saved.Id, default); + saved = await Update(saved); Assert.Null((await Read(saved.Id)).WinnerActivityId); } @@ -112,6 +154,37 @@ public async Task MultipleSelectionsDoNotAutomaticallyChooseWinner() Assert.Null((await Read(saved.Id)).WinnerActivityId); } + [Fact] + public async Task SelectWinnerChoosesAndPersistsOneOfMultipleSelections() + { + var second = new Activity { Id = Guid.NewGuid(), GameId = game.Id, Name = "Other" }; + var input = Event(activity, second); + var saved = Assert.IsType(Assert.IsType(await functions.SaveCustomized(Request(input), default)).Value); + + var selected = Assert.IsType(await functions.SelectWinner(Request(new { }), saved.Id, default)); + var result = Assert.IsType(selected.Value); + Assert.NotNull(result.WinnerActivityId); + Assert.Contains(result.WinnerActivityId.Value, result.Selections.Select(s => s.Activity.Id)); + Assert.Equal(result.WinnerActivityId, rows[saved.Id.ToString()]["WinnerActivityId"]); + await AssertPersisted(result); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ReducingMultipleSelectionsToOneReplacesRemovedOrMissingWinner(bool hadWinner) + { + var removed = new Activity { Id = Guid.NewGuid(), GameId = game.Id, Name = "Removed" }; + var input = Event(activity, removed); + input.WinnerActivityId = hadWinner ? removed.Id : null; + var saved = Assert.IsType(Assert.IsType(await functions.SaveCustomized(Request(input), default)).Value); + saved.Selections.RemoveAt(1); + saved.Name = "Reduced event"; + saved = await Update(saved); + Assert.Single(saved.Selections); + Assert.Equal(activity.Id, saved.WinnerActivityId); + } + [Theory] [InlineData(true)] [InlineData(false)] @@ -139,6 +212,138 @@ public void OneSlotRespectsAllFiltersAndEmptyPools(bool unique) Assert.Null(generator.Generate([game], [activity], request)); } + [Theory] + [InlineData(-1)] + [InlineData(0)] + [InlineData(6)] + [InlineData(int.MaxValue)] + public async Task InvalidCountsAreRejectedWithoutWriting(int count) + { + var request = new GenerateEventRequest { Count = count }; + Assert.IsType(await functions.Generate(Request(request), default)); + Assert.Null(new EventGenerator().Generate([game], [activity], request)); + Assert.Empty(rows); + } + + [Fact] + public async Task OverLimitCountIsRejectedEvenWithEnoughEligibleInventory() + { + var games = Enumerable.Range(0, 6).Select(_ => new GameEntity { Id = Guid.NewGuid() }).ToList(); + var activities = games.Select(g => new TableEntity("activities", Guid.NewGuid().ToString()) { ["GameId"] = g.Id }).ToList(); + gameRows.AddRange(games); + activityRows.AddRange(activities); + + var result = Assert.IsType(await functions.Generate(Request(new GenerateEventRequest { Count = 6 }), default)); + Assert.Contains("between 1 and 5", result.Value?.ToString()); + Assert.Empty(rows); + } + + [Theory] + [InlineData("null")] + [InlineData("{")] + [InlineData("{\"count\":\"one\"}")] + [InlineData("{\"selectedGameIds\":null}")] + [InlineData("{\"selectedThemeIds\":null}")] + [InlineData("{\"selectedHolidayIds\":null}")] + public async Task InvalidGenerationBodiesAreBadRequests(string json) + { + var request = Request(new { }); + request.Body = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + var result = Assert.IsAssignableFrom(await functions.Generate(request, default)); + Assert.Equal(400, result.StatusCode); + Assert.Empty(rows); + } + + [Theory] + [InlineData("empty")] + [InlineData("tooMany")] + [InlineData("nullSelections")] + [InlineData("nullSelection")] + [InlineData("nullActivity")] + [InlineData("nullGame")] + [InlineData("emptyActivityId")] + [InlineData("emptyGameId")] + [InlineData("mismatchedGame")] + [InlineData("duplicateActivity")] + [InlineData("duplicateGame")] + public async Task InvalidCustomizedCreateAndUpdateDoNotWrite(string invalid) + { + var saved = Assert.IsType(Assert.IsType(await functions.SaveCustomized(Request(Event(activity)), default)).Value); + var original = rows[saved.Id.ToString()]; + var input = Event(activity); + switch (invalid) + { + case "empty": input.Selections.Clear(); break; + case "tooMany": input.Selections = Enumerable.Range(0, 6).Select(_ => new Selection { Game = game, Activity = new Activity { Id = Guid.NewGuid(), GameId = game.Id, Name = "Other" } }).ToList(); break; + case "nullSelections": input.Selections = null!; break; + case "nullSelection": input.Selections = [null!]; break; + case "nullActivity": input.Selections = [new Selection { Game = game, Activity = null! }]; break; + case "nullGame": input.Selections = [new Selection { Game = null!, Activity = activity }]; break; + case "emptyActivityId": input.Selections = Event(activity with { Id = Guid.Empty }).Selections; break; + case "emptyGameId": input.Selections = [new Selection { Game = game with { Id = Guid.Empty }, Activity = activity with { GameId = Guid.Empty } }]; break; + case "mismatchedGame": input.Selections = Event(activity with { GameId = Guid.NewGuid() }).Selections; break; + case "duplicateActivity": input.Selections.Add(input.Selections[0]); break; + case "duplicateGame": input.UniqueGamesOnly = true; input.Selections.AddRange(Event(new Activity { Id = Guid.NewGuid(), GameId = game.Id, Name = "Other" }).Selections); break; + } + Assert.IsType(await functions.SaveCustomized(Request(input), default)); + Assert.IsType(await functions.Update(Request(input), saved.Id, default)); + Assert.Single(rows); + Assert.Same(original, rows[saved.Id.ToString()]); + Assert.Equal(activity.Id, (await Read(saved.Id)).WinnerActivityId); + } + + [Fact] + public async Task FiveSelectionsAndExplicitWinnerRoundTripThroughBothReads() + { + var input = Event(Enumerable.Range(0, 5).Select(_ => new Activity { Id = Guid.NewGuid(), GameId = game.Id, Name = "Other" }).ToArray()); + input.WinnerActivityId = input.Selections[2].Activity.Id; + var saved = Assert.IsType(Assert.IsType(await functions.SaveCustomized(Request(input), default)).Value); + Assert.Equal(input.WinnerActivityId, (await Read(saved.Id)).WinnerActivityId); + var list = Assert.IsType>(Assert.IsType(await functions.GetAll(Request(new { }), default)).Value); + Assert.Equal(input.WinnerActivityId, Assert.Single(list).WinnerActivityId); + saved.WinnerActivityId = saved.Selections[4].Activity.Id; + var explicitWinner = saved.WinnerActivityId; + saved = await Update(saved); + Assert.Equal(explicitWinner, saved.WinnerActivityId); + saved.WinnerActivityId = null; + saved = await Update(saved); + Assert.Null((await Read(saved.Id)).WinnerActivityId); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void GeneratorAcceptsUpperBoundaryWithoutRepeatingActivities(bool unique) + { + var games = Enumerable.Range(0, 5).Select(i => new Game { Id = Guid.NewGuid(), Name = $"Game {i}" }).ToList(); + // With repeated games allowed, use one game so random retry exhaustion cannot flake. + if (!unique) games = [games[0]]; + var activities = Enumerable.Range(0, 5).Select(i => new Activity { Id = Guid.NewGuid(), GameId = games[unique ? i : 0].Id, Name = "Activity" }).ToList(); + var selections = new EventGenerator().Generate(games, activities, new GenerateEventRequest { Count = 5, UniqueGamesOnly = unique }); + Assert.NotNull(selections); + Assert.Equal(5, selections.Count); + Assert.Equal(5, selections.Select(s => s.Activity.Id).Distinct().Count()); + if (unique) Assert.Equal(5, selections.Select(s => s.Game.Id).Distinct().Count()); + } + + [Theory] + [InlineData("null")] + [InlineData("{")] + public async Task InvalidCustomizedBodiesDoNotOverwriteSavedEvent(string json) + { + var saved = Assert.IsType(Assert.IsType(await functions.SaveCustomized(Request(Event(activity)), default)).Value); + HttpRequest Body() + { + var request = Request(new { }); + request.Body = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + return request; + } + Assert.IsType(await functions.SaveCustomized(Body(), default)); + Assert.IsType(await functions.Update(Body(), saved.Id, default)); + Assert.Single(rows); + Assert.Equal(activity.Id, (await Read(saved.Id)).WinnerActivityId); + } + [Fact] public void MultiSlotUniqueGamesAndDuplicateActivityConstraintsRemain() { diff --git a/MW-GC.EventManager.Tests/SlotRerollTests.cs b/MW-GC.EventManager.Tests/SlotRerollTests.cs index ce599fe..ca60319 100644 --- a/MW-GC.EventManager.Tests/SlotRerollTests.cs +++ b/MW-GC.EventManager.Tests/SlotRerollTests.cs @@ -172,6 +172,8 @@ public async Task OneSlotCreateAndEditSendAutomaticWinnerAndReload() typeof(Events).GetMethod("ShowEditEvent", Private)!.Invoke(page, [handler.Saved]); Assert.Single(Slots.Cast()); + Set("_custGameIds", new List { gameA }); + Assert.Equal(replacement.Id, Assert.Single(Candidates()).Id); Call("RerollSlot", 0); var replacementId = Id(Slots[0]!, "ActivityId"); await (Task)typeof(Events).GetMethod("SaveCustomizedEvent", Private)!.Invoke(page, null)!; @@ -197,15 +199,202 @@ public void OneSlotRandomizationAndRerollRespectFilters(bool unique) Assert.Empty(Candidates()); } + [Theory] + [InlineData(0)] + [InlineData(6)] + public async Task InvalidSlotCountDoesNotSendRequest(int count) + { + Slots.Clear(); + // Every slot is otherwise valid, isolating the count guard from duplicate guards. + Set("_custUniqueGames", false); + for (var i = 0; i < count; i++) + { + var activity = Activity(gameA); + Activities.Add(activity); + AddSlot(activity); + } + var handler = ConfigureSave(); + await Save(); + Assert.Null(handler.LastWrite); + Assert.Contains("Select 1–5", (string)Get("_customizeError")!); + Assert.Equal(true, Get("_showCustomize")); + Assert.Equal(false, Get("_generating")); + } + + [Fact] + public async Task IncompleteSlotIsNotSilentlyDroppedIntoAnAutomaticWinner() + { + Slots[1]!.GetType().GetProperty("ActivityId")!.SetValue(Slots[1], Guid.Empty); + var handler = ConfigureSave(); + await Save(); + Assert.Null(handler.LastWrite); + Assert.Contains("every slot", (string)Get("_customizeError")!); + Assert.Equal(2, Slots.Count); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SaveFailureKeepsDialogAndSelectionsForRetry(bool networkFailure) + { + Call("RemoveSlot", 1); + var handler = ConfigureSave(); + handler.Fail = true; + handler.NetworkFailure = networkFailure; + await Save(); + Assert.Equal(true, Get("_showCustomize")); + Assert.Equal(false, Get("_generating")); + Assert.NotNull(Get("_customizeError")); + Assert.Equal(0, handler.Reads); + Assert.Equal(current.Id, Id(Slots[0]!, "ActivityId")); + handler.Fail = false; + handler.NetworkFailure = false; + await Save(); + Assert.Null(Get("_customizeError")); + Assert.Equal(false, Get("_showCustomize")); + Assert.Equal(1, handler.Reads); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task MultiSelectionEditPreservesOnlyAnExistingWinner(bool replaceWinner) + { + var handler = ConfigureSave(); + await Save(); + Assert.Null(handler.Saved!.WinnerActivityId); + handler.Saved.WinnerActivityId = current.Id; + typeof(Events).GetMethod("ShowEditEvent", Private)!.Invoke(page, [handler.Saved]); + if (replaceWinner) Call("RerollSlot", 0); + await Save(); + Assert.Equal(HttpMethod.Put, handler.LastWrite); + Assert.Equal(2, handler.Saved!.Selections.Count); + Assert.Equal(replaceWinner ? (Guid?)null : current.Id, handler.Saved.WinnerActivityId); + } + + [Fact] + public async Task SavedSoleWinnerIsReadBackForListAndDetails() + { + Call("RemoveSlot", 1); + var handler = ConfigureSave(); + await Save(); + var persisted = Assert.Single((List)Get("_events")!); + Assert.NotSame(handler.Saved, persisted); + typeof(Events).GetMethod("ShowDetails", Private)!.Invoke(page, [persisted]); + var detail = (EventEntity)Get("_detailEvent")!; + Assert.Equal(Assert.Single(detail.Selections).Activity.Id, detail.WinnerActivityId); + Assert.Same(persisted, detail); + Assert.Equal(1, handler.Reads); + } + + [Fact] + public async Task SuccessfulSaveWithFailedReadBackDoesNotOfferCreateRetry() + { + Call("RemoveSlot", 1); + var handler = ConfigureSave(); + handler.FailRead = true; + await Save(); + Assert.Equal(current.Id, handler.Saved!.WinnerActivityId); + Assert.Equal(false, Get("_showCustomize")); + Assert.Null(Get("_customizeError")); + Assert.Contains("event was saved", (string)Get("_pageError")!); + Assert.Equal(false, Get("_generating")); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task SameGameSelectionsRequireUniqueGamesToBeDisabled(bool unique) + { + Slots.Clear(); + AddSlot(current); + AddSlot(replacement); + Set("_custUniqueGames", unique); + var handler = ConfigureSave(); + await Save(); + if (unique) + { + Assert.Null(handler.Saved); + Assert.Contains("distinct", (string)Get("_customizeError")!); + } + else + { + Assert.Equal(2, handler.Saved!.Selections.Count); + Assert.False(handler.Saved.UniqueGamesOnly); + Assert.Null(handler.Saved.WinnerActivityId); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task DuplicateActivityIsRejectedEvenWhenRepeatedGamesAreAllowed(bool unique) + { + Slots.Clear(); + AddSlot(current); + AddSlot(current); + Set("_custUniqueGames", unique); + var handler = ConfigureSave(); + await Save(); + Assert.Null(handler.LastWrite); + Assert.Null(handler.Saved); + Assert.Equal(0, handler.Reads); + Assert.Contains("distinct", (string)Get("_customizeError")!); + Assert.Equal(true, Get("_showCustomize")); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task MultiSlotEditReducedToOneReloadsAutomaticWinner(bool hadWinner) + { + var handler = ConfigureSave(); + await Save(); + Assert.NotNull(handler.Saved); + handler.Saved.WinnerActivityId = hadWinner ? other.Id : null; + typeof(Events).GetMethod("ShowEditEvent", Private)!.Invoke(page, [handler.Saved]); + Call("RemoveSlot", 1); + await Save(); + Assert.Null(Get("_customizeError")); + Assert.Equal(HttpMethod.Put, handler.LastWrite); + Assert.Equal(current.Id, Assert.Single(handler.Saved.Selections).Activity.Id); + Assert.Equal(current.Id, handler.Saved.WinnerActivityId); + var listed = Assert.Single((List)Get("_events")!); + typeof(Events).GetMethod("ShowDetails", Private)!.Invoke(page, [listed]); + Assert.Equal(current.Id, ((EventEntity)Get("_detailEvent")!).WinnerActivityId); + } + + private EventHttpHandler ConfigureSave() + { + Set("_custName", "Test event"); + Set("_showCustomize", true); + var handler = new EventHttpHandler(); + var service = new MW_GC.EventManager.Web.Services.EventService(new HttpClient(handler) { BaseAddress = new Uri("https://example.test") }); + typeof(Events).GetProperty("EventSvc", Private)!.SetValue(page, service); + return handler; + } + + private Task Save() => (Task)typeof(Events).GetMethod("SaveCustomizedEvent", Private)!.Invoke(page, null)!; + private sealed class EventHttpHandler : HttpMessageHandler { + public bool Fail { get; set; } + public bool NetworkFailure { get; set; } + public bool FailRead { get; set; } + public int Reads { get; private set; } public EventEntity? Saved { get; private set; } public HttpMethod? LastWrite { get; private set; } protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { if (request.Method == HttpMethod.Get) + { + Reads++; + if (FailRead) throw new HttpRequestException("Read unavailable"); return new(System.Net.HttpStatusCode.OK) { Content = System.Net.Http.Json.JsonContent.Create(new[] { Saved! }) }; + } + if (NetworkFailure) throw new HttpRequestException("Offline"); LastWrite = request.Method; + if (Fail) return new(System.Net.HttpStatusCode.BadRequest) { Content = new StringContent("Invalid activity") }; Saved = System.Text.Json.JsonSerializer.Deserialize(await request.Content!.ReadAsStringAsync(cancellationToken), new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)); return new(System.Net.HttpStatusCode.OK) { Content = System.Net.Http.Json.JsonContent.Create(Saved) }; } diff --git a/MW-GC.EventManager.Web/Pages/Events.razor b/MW-GC.EventManager.Web/Pages/Events.razor index ab8674f..387f7f4 100644 --- a/MW-GC.EventManager.Web/Pages/Events.razor +++ b/MW-GC.EventManager.Web/Pages/Events.razor @@ -8,6 +8,10 @@ @inject HolidayService HolidaySvc +@if (_pageError is not null) +{ +
@_pageError
+} @if (_events is null) @@ -169,6 +173,10 @@
Game & Activity Selections (@_custSelections.Count) Select 1–5 activities. If only one activity is selected when saved, it automatically becomes the winner. + @if (_custSelections.Count == 1) + { + Single activity: this activity will automatically win when you save. + } @if (_custThemedOnly || _custGameIds.Count > 0 || _custThemeIds.Count > 0 || _custHolidayIds.Count > 0) { Filters apply when randomizing all selections or re-rolling a slot @@ -258,6 +266,10 @@ + @if (_customizeError is not null) + { +
@_customizeError
+ } Cancel + @if (_detailEvent.Selections.Count == 1 && _detailEvent.WinnerActivityId == _detailEvent.Selections[0].Activity.Id) + { + With one activity, the winner is selected automatically. + } @foreach (var selection in _detailEvent.Selections) { var isWinner = _detailEvent.WinnerActivityId.HasValue && _detailEvent.WinnerActivityId.Value == selection.Activity.Id; @@ -397,6 +413,8 @@ // Customize dialog state private bool _showCustomize; private bool _generating; + private string? _customizeError; + private string? _pageError; private EventEntity? _editingEvent; private string _custName = string.Empty; private DateTime? _custDate = DateTime.Today; @@ -455,6 +473,7 @@ private void ShowCustomize() { + _customizeError = null; _editingEvent = null; _custName = string.Empty; _custDate = DateTime.Today; @@ -471,6 +490,7 @@ private void ShowEditEvent(EventEntity e) { + _customizeError = null; _editingEvent = e; _custName = e.Name; var localStart = e.Date.ToLocalTime().DateTime; @@ -630,11 +650,31 @@ private async Task SaveCustomizedEvent() { if (_generating || string.IsNullOrWhiteSpace(_custName)) return; + _customizeError = null; + if (_custSelections.Count is < 1 or > 5) + { + _customizeError = "Select 1–5 activities before saving. Adjust the filters or add an activity slot."; + return; + } + if (_custSelections.Any(s => s.GameId == Guid.Empty || s.ActivityId == Guid.Empty || + _games?.Any(g => g.Id == s.GameId) != true || + _activities?.Any(a => a.Id == s.ActivityId && a.GameId == s.GameId) != true)) + { + _customizeError = "Choose a valid game and activity for every slot before saving."; + return; + } + if (_custSelections.Select(s => s.ActivityId).Distinct().Count() != _custSelections.Count || + (_custUniqueGames && _custSelections.Select(s => s.GameId).Distinct().Count() != _custSelections.Count)) + { + _customizeError = "Activities must be distinct, and games must be distinct when Unique games only is enabled."; + return; + } _generating = true; + var saved = false; + _pageError = null; try { var selections = _custSelections - .Where(s => s.GameId != Guid.Empty && s.ActivityId != Guid.Empty) .Select(s => { var game = _games!.First(g => g.Id == s.GameId); @@ -662,14 +702,29 @@ entity.NormalizeWinner(); - if (_editingEvent is not null) - await EventSvc.UpdateAsync(entity); - else - await EventSvc.SaveAsync(entity); - + using var response = _editingEvent is not null + ? await EventSvc.UpdateAsync(entity) + : await EventSvc.SaveAsync(entity); + if (!response.IsSuccessStatusCode) + { + var message = await response.Content.ReadAsStringAsync(); + _customizeError = string.IsNullOrWhiteSpace(message) + ? $"Unable to save the event ({(int)response.StatusCode}). Please try again." + : $"Unable to save the event: {message}"; + return; + } + + saved = true; _showCustomize = false; await Reload(); } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + if (saved) + _pageError = "The event was saved, but the list could not be refreshed. Reload the page to see the saved event."; + else + _customizeError = "Unable to contact the server. Please try again."; + } finally { _generating = false; } } From fd10e721d27c9d7b5cd7338fe93436707bfd13fb Mon Sep 17 00:00:00 2001 From: "MWG Bender[bot]" <331880973+mwg-bender[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:07:06 +0000 Subject: [PATCH 3/6] fix(events): handle invalid JSON after a successful save --- MW-GC.EventManager.Tests/SlotRerollTests.cs | 13 ++++++++++--- MW-GC.EventManager.Web/Pages/Events.razor | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/MW-GC.EventManager.Tests/SlotRerollTests.cs b/MW-GC.EventManager.Tests/SlotRerollTests.cs index ca60319..f5d57df 100644 --- a/MW-GC.EventManager.Tests/SlotRerollTests.cs +++ b/MW-GC.EventManager.Tests/SlotRerollTests.cs @@ -287,12 +287,16 @@ public async Task SavedSoleWinnerIsReadBackForListAndDetails() Assert.Equal(1, handler.Reads); } - [Fact] - public async Task SuccessfulSaveWithFailedReadBackDoesNotOfferCreateRetry() + [Theory] + [InlineData(null)] + [InlineData("{")] + [InlineData("[{\"id\":\"not-a-guid\"}]")] + public async Task SuccessfulSaveWithFailedReadBackDoesNotOfferCreateRetry(string? readJson) { Call("RemoveSlot", 1); var handler = ConfigureSave(); - handler.FailRead = true; + handler.FailRead = readJson is null; + handler.ReadJson = readJson; await Save(); Assert.Equal(current.Id, handler.Saved!.WinnerActivityId); Assert.Equal(false, Get("_showCustomize")); @@ -381,6 +385,7 @@ private sealed class EventHttpHandler : HttpMessageHandler public bool Fail { get; set; } public bool NetworkFailure { get; set; } public bool FailRead { get; set; } + public string? ReadJson { get; set; } public int Reads { get; private set; } public EventEntity? Saved { get; private set; } public HttpMethod? LastWrite { get; private set; } @@ -390,6 +395,8 @@ protected override async Task SendAsync(HttpRequestMessage { Reads++; if (FailRead) throw new HttpRequestException("Read unavailable"); + if (ReadJson is not null) + return new(System.Net.HttpStatusCode.OK) { Content = new StringContent(ReadJson, System.Text.Encoding.UTF8, "application/json") }; return new(System.Net.HttpStatusCode.OK) { Content = System.Net.Http.Json.JsonContent.Create(new[] { Saved! }) }; } if (NetworkFailure) throw new HttpRequestException("Offline"); diff --git a/MW-GC.EventManager.Web/Pages/Events.razor b/MW-GC.EventManager.Web/Pages/Events.razor index 387f7f4..ef9a376 100644 --- a/MW-GC.EventManager.Web/Pages/Events.razor +++ b/MW-GC.EventManager.Web/Pages/Events.razor @@ -718,7 +718,7 @@ _showCustomize = false; await Reload(); } - catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or System.Text.Json.JsonException) { if (saved) _pageError = "The event was saved, but the list could not be refreshed. Reload the page to see the saved event."; From 0f640872a9a5fcdeb5fb1e61abad9a54179d0ec6 Mon Sep 17 00:00:00 2001 From: "MWG Bender[bot]" <331880973+mwg-bender[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:14:15 +0000 Subject: [PATCH 4/6] fix(events): make customized create retries idempotent Retain a per-dialog request key and use atomic table inserts so a lost response cannot create a second event. Reconcile matching retries and reject changed payloads without overwriting saved data. --- .../Functions/EventFunctions.cs | 20 ++- MW-GC.EventManager.API/Services/TableStore.cs | 25 +++- .../SingleActivityApiTests.cs | 124 +++++++++++++++++- MW-GC.EventManager.Tests/SlotRerollTests.cs | 52 ++++++++ MW-GC.EventManager.Web/Pages/Events.razor | 5 +- .../Services/EventService.cs | 10 +- docs/event-create-retries.md | 9 ++ 7 files changed, 239 insertions(+), 6 deletions(-) create mode 100644 docs/event-create-retries.md diff --git a/MW-GC.EventManager.API/Functions/EventFunctions.cs b/MW-GC.EventManager.API/Functions/EventFunctions.cs index fa7607c..6fa9cf9 100644 --- a/MW-GC.EventManager.API/Functions/EventFunctions.cs +++ b/MW-GC.EventManager.API/Functions/EventFunctions.cs @@ -100,9 +100,25 @@ public async Task SaveCustomized( if (entity is null) return new BadRequestResult(); if (entity.ValidateSelections() is { } error) return new BadRequestObjectResult(error); - entity.Id = Guid.NewGuid(); + // Optional for older clients. The key identifies one create, never an update. + var id = Guid.NewGuid(); + if (req.Headers.TryGetValue("Idempotency-Key", out var key) && + (key.Count != 1 || !Guid.TryParseExact(key[0], "D", out id) || id == Guid.Empty)) + return new BadRequestObjectResult("Idempotency-Key must be a non-empty GUID in D format."); + + entity.Id = id; entity.NormalizeWinner(); - await _store.UpsertAsync(entity, ct); + if (!await _store.TryAddAsync(entity, ct)) + { + var existing = await _store.GetAsync(id, ct); + // Do not silently discard edits made after an ambiguous response, or overwrite + // a subsequently edited event. Storage metadata is not part of the request. + if (existing is null || existing.Name != entity.Name || existing.Date != entity.Date || + existing.UniqueGamesOnly != entity.UniqueGamesOnly || existing.WinnerActivityId != entity.WinnerActivityId || + System.Text.Json.JsonSerializer.Serialize(existing.Selections) != System.Text.Json.JsonSerializer.Serialize(entity.Selections)) + return new ConflictObjectResult("This create was already saved with different details. Reload the page and edit the saved event."); + return new OkObjectResult(existing); + } return new CreatedResult($"/api/events/{entity.Id}", entity); } diff --git a/MW-GC.EventManager.API/Services/TableStore.cs b/MW-GC.EventManager.API/Services/TableStore.cs index 1a57a23..f5bf6cd 100644 --- a/MW-GC.EventManager.API/Services/TableStore.cs +++ b/MW-GC.EventManager.API/Services/TableStore.cs @@ -97,6 +97,29 @@ public async Task UpsertAsync(TEntity entity, CancellationToken ct = default) return; } + await _table.UpsertEntityAsync(Flatten(entity), TableUpdateMode.Replace, ct); + } + + /// Atomically inserts without replacing an existing row, including on concurrent retries. + public async Task TryAddAsync(TEntity entity, CancellationToken ct = default) + { + entity.PartitionKey = _partitionKey; + try + { + if (ComplexProps.Length == 0) + await _table.AddEntityAsync(entity, ct); + else + await _table.AddEntityAsync(Flatten(entity), ct); + return true; + } + catch (RequestFailedException ex) when (ex.Status == 409 && ex.ErrorCode == "EntityAlreadyExists") + { + return false; + } + } + + private static TableEntity Flatten(TEntity entity) + { // Flatten complex properties to JSON strings. var row = new TableEntity(entity.PartitionKey, entity.RowKey); foreach (var prop in typeof(TEntity).GetProperties(BindingFlags.Public | BindingFlags.Instance)) @@ -112,7 +135,7 @@ or nameof(EntityBase.Timestamp) or nameof(EntityBase.ETag) or nameof(EntityBase. else row[prop.Name] = value; } - await _table.UpsertEntityAsync(row, TableUpdateMode.Replace, ct); + return row; } public async Task DeleteAsync(Guid id, CancellationToken ct = default) diff --git a/MW-GC.EventManager.Tests/SingleActivityApiTests.cs b/MW-GC.EventManager.Tests/SingleActivityApiTests.cs index 4834ddb..d690a49 100644 --- a/MW-GC.EventManager.Tests/SingleActivityApiTests.cs +++ b/MW-GC.EventManager.Tests/SingleActivityApiTests.cs @@ -17,7 +17,10 @@ namespace MW_GC.EventManager.Tests; public class SingleActivityApiTests { - private readonly Dictionary rows = []; + private readonly System.Collections.Concurrent.ConcurrentDictionary rows = new(); + private bool failBeforeInsert; + private bool failAfterInsert; + private Func? beforeInsert; private readonly List gameRows = []; private readonly List activityRows = []; private readonly EventFunctions functions; @@ -31,6 +34,16 @@ public SingleActivityApiTests() table.Setup(t => t.UpsertEntityAsync(It.IsAny(), TableUpdateMode.Replace, It.IsAny())) .Callback((row, _, _) => rows[row.RowKey] = new TableEntity(row)) .ReturnsAsync(Mock.Of()); + table.Setup(t => t.AddEntityAsync(It.IsAny(), It.IsAny())) + .Returns(async (TableEntity row, CancellationToken _) => + { + if (beforeInsert is not null) await beforeInsert(); + if (failBeforeInsert) throw new RequestFailedException(503, "Storage unavailable"); + if (!rows.TryAdd(row.RowKey, new TableEntity(row))) + throw new RequestFailedException(409, "Entity already exists", "EntityAlreadyExists", null); + if (failAfterInsert) throw new RequestFailedException(503, "Insert response lost"); + return Mock.Of(); + }); table.Setup(t => t.GetEntityAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync((string _, string id, IEnumerable _, CancellationToken _) => Response.FromValue(rows[id], Mock.Of())); table.Setup(t => t.QueryAsync(It.IsAny>>(), It.IsAny(), It.IsAny>(), It.IsAny())) @@ -84,6 +97,115 @@ private async Task AssertPersisted(EventEntity expected) } } + [Fact] + public async Task RetryingCreateAfterLostResponseReturnsSameEventWithoutOverwriting() + { + var input = Event(activity); + var key = Guid.NewGuid().ToString("D"); + HttpRequest CreateRequest() + { + var request = Request(input); + request.Headers["Idempotency-Key"] = key; + return request; + } + + // The create commits, but its response never reaches the client. + var first = Assert.IsType(await functions.SaveCustomized(CreateRequest(), default)); + var saved = Assert.IsType(first.Value); + var retry = Assert.IsType(await functions.SaveCustomized(CreateRequest(), default)); + Assert.Equal(saved.Id, Assert.IsType(retry.Value).Id); + await AssertPersisted(saved); + + input.Name = "Changed during retry"; + Assert.IsType(await functions.SaveCustomized(CreateRequest(), default)); + await AssertPersisted(saved); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StorageFailureBeforeOrAfterCommitCanBeRetried(bool committed) + { + var input = Event(activity); + var key = Guid.NewGuid(); + HttpRequest CreateRequest() + { + var request = Request(input); + request.Headers["Idempotency-Key"] = key.ToString("D"); + return request; + } + failBeforeInsert = !committed; + failAfterInsert = committed; + await Assert.ThrowsAsync(() => functions.SaveCustomized(CreateRequest(), default)); + failBeforeInsert = failAfterInsert = false; + var result = Assert.IsAssignableFrom(await functions.SaveCustomized(CreateRequest(), default)); + Assert.Equal(committed ? 200 : 201, result.StatusCode); + var saved = Assert.IsType(result.Value); + Assert.Equal(key, saved.Id); + await AssertPersisted(saved); + } + + [Fact] + public async Task ConcurrentCreatesWithSameKeyInsertOnlyOnce() + { + var bothEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var arrivals = 0; + beforeInsert = () => + { + if (Interlocked.Increment(ref arrivals) == 2) bothEntered.SetResult(); + return bothEntered.Task; + }; + var input = Event(activity); + var key = Guid.NewGuid().ToString("D"); + HttpRequest CreateRequest() + { + var request = Request(input); + request.Headers["Idempotency-Key"] = key; + return request; + } + var results = await Task.WhenAll( + functions.SaveCustomized(CreateRequest(), default), + functions.SaveCustomized(CreateRequest(), default)).WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Single(results.OfType()); + Assert.Single(results.OfType()); + var saved = Assert.IsType(results.OfType().Single().Value); + await AssertPersisted(saved); + } + + [Theory] + [InlineData("")] + [InlineData("not-a-guid")] + [InlineData("00000000-0000-0000-0000-000000000000")] + [InlineData("efb26a9120f848dfb67b104c4096da81")] + public async Task InvalidCreateKeysAreRejectedWithoutWriting(string key) + { + var request = Request(Event(activity)); + request.Headers["Idempotency-Key"] = key; + Assert.IsType(await functions.SaveCustomized(request, default)); + Assert.Empty(rows); + } + + [Fact] + public async Task MultipleCreateKeysAreRejectedWithoutWriting() + { + var request = Request(Event(activity)); + request.Headers["Idempotency-Key"] = new Microsoft.Extensions.Primitives.StringValues([Guid.NewGuid().ToString("D"), Guid.NewGuid().ToString("D")]); + Assert.IsType(await functions.SaveCustomized(request, default)); + Assert.Empty(rows); + } + + [Fact] + public async Task LegacyCreatesStillReceiveIndependentServerIds() + { + var input = Event(activity); + var first = Assert.IsType(Assert.IsType(await functions.SaveCustomized(Request(input), default)).Value); + var second = Assert.IsType(Assert.IsType(await functions.SaveCustomized(Request(input), default)).Value); + Assert.NotEqual(input.Id, first.Id); + Assert.NotEqual(first.Id, second.Id); + var listed = Assert.IsType>(Assert.IsType(await functions.GetAll(Request(new { }), default)).Value); + Assert.Equal(2, listed.Count); + } + private async Task Update(EventEntity input) { // A rejected edit must fail here, not masquerade as a winner-storage regression. diff --git a/MW-GC.EventManager.Tests/SlotRerollTests.cs b/MW-GC.EventManager.Tests/SlotRerollTests.cs index f5d57df..75a29fb 100644 --- a/MW-GC.EventManager.Tests/SlotRerollTests.cs +++ b/MW-GC.EventManager.Tests/SlotRerollTests.cs @@ -368,6 +368,39 @@ public async Task MultiSlotEditReducedToOneReloadsAutomaticWinner(bool hadWinner Assert.Equal(current.Id, ((EventEntity)Get("_detailEvent")!).WinnerActivityId); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AmbiguousCreateRetryReusesKeyAndNewDialogGetsNewKey(bool timeout) + { + Call("RemoveSlot", 1); + var handler = ConfigureSave(); + handler.LoseWriteResponse = true; + handler.Timeout = timeout; + await Save(); + Assert.Equal(true, Get("_showCustomize")); + Assert.NotNull(Get("_customizeError")); + var key = Assert.Single(handler.CreateKeys); + Assert.True(Guid.TryParse(key, out var id)); + Assert.NotEqual(Guid.Empty, id); + var committedId = handler.Saved!.Id; + + handler.LoseWriteResponse = false; + await Save(); + Assert.Equal(new[] { key, key }, handler.CreateKeys); + Assert.Equal(committedId, handler.Saved.Id); + Assert.Single(handler.Created); + Assert.Equal(false, Get("_showCustomize")); + Assert.Null(Get("_customizeError")); + Assert.Equal(committedId, Assert.Single((List)Get("_events")!).Id); + + typeof(Events).GetMethod("ShowCustomize", Private)!.Invoke(page, null); + Set("_custName", "Another event"); + await Save(); + Assert.NotEqual(key, handler.CreateKeys[2]); + Assert.Equal(2, handler.Created.Count); + } + private EventHttpHandler ConfigureSave() { Set("_custName", "Test event"); @@ -383,6 +416,10 @@ private EventHttpHandler ConfigureSave() private sealed class EventHttpHandler : HttpMessageHandler { public bool Fail { get; set; } + public bool LoseWriteResponse { get; set; } + public bool Timeout { get; set; } + public List CreateKeys { get; } = []; + public Dictionary Created { get; } = []; public bool NetworkFailure { get; set; } public bool FailRead { get; set; } public string? ReadJson { get; set; } @@ -403,6 +440,21 @@ protected override async Task SendAsync(HttpRequestMessage LastWrite = request.Method; if (Fail) return new(System.Net.HttpStatusCode.BadRequest) { Content = new StringContent("Invalid activity") }; Saved = System.Text.Json.JsonSerializer.Deserialize(await request.Content!.ReadAsStringAsync(cancellationToken), new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)); + if (request.Method == HttpMethod.Post) + { + var key = request.Headers.TryGetValues("Idempotency-Key", out var keys) ? keys.Single() : null; + CreateKeys.Add(key); + Saved!.Id = key is null ? Guid.NewGuid() : Guid.Parse(key); + Created.TryAdd(Saved.Id, Saved); + Saved = Created[Saved.Id]; + } + else + Assert.False(request.Headers.Contains("Idempotency-Key")); + if (LoseWriteResponse) + { + if (Timeout) throw new TaskCanceledException("Response timed out after commit"); + throw new HttpRequestException("Response lost after commit"); + } return new(System.Net.HttpStatusCode.OK) { Content = System.Net.Http.Json.JsonContent.Create(Saved) }; } } diff --git a/MW-GC.EventManager.Web/Pages/Events.razor b/MW-GC.EventManager.Web/Pages/Events.razor index ef9a376..112a8c1 100644 --- a/MW-GC.EventManager.Web/Pages/Events.razor +++ b/MW-GC.EventManager.Web/Pages/Events.razor @@ -416,6 +416,8 @@ private string? _customizeError; private string? _pageError; private EventEntity? _editingEvent; + // Retain across failed saves; only a new create dialog starts a new operation. + private Guid _createKey = Guid.NewGuid(); private string _custName = string.Empty; private DateTime? _custDate = DateTime.Today; private DateTime? _custTime = DateTime.Today.AddHours(19); @@ -475,6 +477,7 @@ { _customizeError = null; _editingEvent = null; + _createKey = Guid.NewGuid(); _custName = string.Empty; _custDate = DateTime.Today; _custTime = DateTime.Today.AddHours(19); @@ -704,7 +707,7 @@ using var response = _editingEvent is not null ? await EventSvc.UpdateAsync(entity) - : await EventSvc.SaveAsync(entity); + : await EventSvc.SaveAsync(entity, _createKey); if (!response.IsSuccessStatusCode) { var message = await response.Content.ReadAsStringAsync(); diff --git a/MW-GC.EventManager.Web/Services/EventService.cs b/MW-GC.EventManager.Web/Services/EventService.cs index d96e2c2..f7a1323 100644 --- a/MW-GC.EventManager.Web/Services/EventService.cs +++ b/MW-GC.EventManager.Web/Services/EventService.cs @@ -9,7 +9,15 @@ public sealed class EventService(HttpClient http) public Task?> GetAllAsync() => http.GetFromJsonAsync>("api/events"); public Task GetAsync(Guid id) => http.GetFromJsonAsync($"api/events/{id}"); public Task GenerateAsync(GenerateEventRequest request) => http.PostAsJsonAsync("api/events/generate", request); - public Task SaveAsync(EventEntity entity) => http.PostAsJsonAsync("api/events", entity); + public async Task SaveAsync(EventEntity entity, Guid idempotencyKey) + { + using var request = new HttpRequestMessage(HttpMethod.Post, "api/events") + { + Content = JsonContent.Create(entity) + }; + request.Headers.Add("Idempotency-Key", idempotencyKey.ToString("D")); + return await http.SendAsync(request); + } public Task UpdateAsync(EventEntity entity) => http.PutAsJsonAsync($"api/events/{entity.Id}", entity); public Task DeleteAsync(Guid id) => http.DeleteAsync($"api/events/{id}"); public Task SelectWinnerAsync(Guid id) => http.PostAsync($"api/events/{id}/winner", null); diff --git a/docs/event-create-retries.md b/docs/event-create-retries.md new file mode 100644 index 0000000..34fdce5 --- /dev/null +++ b/docs/event-create-retries.md @@ -0,0 +1,9 @@ +# Customized event create retries + +`POST /api/events` accepts an optional `Idempotency-Key` header containing one non-empty GUID in `D` format. The key becomes the event ID; body IDs remain ignored. The UI generates a key for each create dialog and retains it across failed saves, including lost responses and timeouts. Updates still use PUT. + +The API uses Azure Table Storage's atomic insert, not upsert or a read-before-write check. An existing row cannot be replaced by a retry. An identical normalized payload returns the existing event (200); a new insert returns 201. If the stored details differ, the API returns 409 with instructions to reload and edit the saved event. Invalid keys return 400. Requests without a key retain legacy server-generated IDs and are not retry-safe. + +The event row itself is the deduplication record, so protection lasts while that row exists. This is not a durable request ledger: reopening the dialog or reloading the browser starts a new create, and replay after deletion can recreate the same ID. The generated-event endpoint is unchanged. Deploy the API support before the updated UI; an older API ignores the header. + +Tests exercise the actual API functions against a mocked Azure TableClient and the existing page-save handler seam against a deterministic HttpMessageHandler. They simulate lost HTTP responses after commit, failures before and after storage commit, and concurrent inserts using a barrier rather than timing. These are not live Azure or browser end-to-end tests. From f9cd6e35766b45f48f3480bede6ae2939ebf3063 Mon Sep 17 00:00:00 2001 From: "MWG Bender[bot]" <331880973+mwg-bender[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:00:18 +0000 Subject: [PATCH 5/6] fix(events): make customized create retries idempotent --- .../Functions/EventFunctions.cs | 29 +- .../IdempotentCreateTests.cs | 248 ++++++++++++++++++ MW-GC.EventManager.Web/Pages/Events.razor | 4 +- docs/idempotent-event-creates.md | 18 ++ 4 files changed, 284 insertions(+), 15 deletions(-) create mode 100644 MW-GC.EventManager.Tests/IdempotentCreateTests.cs create mode 100644 docs/idempotent-event-creates.md diff --git a/MW-GC.EventManager.API/Functions/EventFunctions.cs b/MW-GC.EventManager.API/Functions/EventFunctions.cs index 6fa9cf9..78cdd7e 100644 --- a/MW-GC.EventManager.API/Functions/EventFunctions.cs +++ b/MW-GC.EventManager.API/Functions/EventFunctions.cs @@ -100,28 +100,31 @@ public async Task SaveCustomized( if (entity is null) return new BadRequestResult(); if (entity.ValidateSelections() is { } error) return new BadRequestObjectResult(error); - // Optional for older clients. The key identifies one create, never an update. - var id = Guid.NewGuid(); - if (req.Headers.TryGetValue("Idempotency-Key", out var key) && - (key.Count != 1 || !Guid.TryParseExact(key[0], "D", out id) || id == Guid.Empty)) + var hasKey = req.Headers.TryGetValue("Idempotency-Key", out var keys); + var createId = Guid.NewGuid(); + if (hasKey && (keys.Count != 1 || !Guid.TryParseExact(keys[0], "D", out createId) || createId == Guid.Empty)) return new BadRequestObjectResult("Idempotency-Key must be a non-empty GUID in D format."); - entity.Id = id; + entity.Id = createId; entity.NormalizeWinner(); + // Older clients still get a fresh ID; all creates use atomic inserts. if (!await _store.TryAddAsync(entity, ct)) { - var existing = await _store.GetAsync(id, ct); - // Do not silently discard edits made after an ambiguous response, or overwrite - // a subsequently edited event. Storage metadata is not part of the request. - if (existing is null || existing.Name != entity.Name || existing.Date != entity.Date || - existing.UniqueGamesOnly != entity.UniqueGamesOnly || existing.WinnerActivityId != entity.WinnerActivityId || - System.Text.Json.JsonSerializer.Serialize(existing.Selections) != System.Text.Json.JsonSerializer.Serialize(entity.Selections)) - return new ConflictObjectResult("This create was already saved with different details. Reload the page and edit the saved event."); - return new OkObjectResult(existing); + var existing = await _store.GetAsync(createId, ct); + if (existing is not null && CreateDetails(existing) == CreateDetails(entity)) + return new OkObjectResult(existing); + return new ConflictObjectResult("This create key already exists with different details. Reload the event list and edit the saved event; do not start another create to retry this save."); } return new CreatedResult($"/api/events/{entity.Id}", entity); } + // Compare normalized domain data, not row keys, timestamps or ETags. Never overwrite + // a later edit when reconciling an ambiguous create response. + private static string CreateDetails(EventEntity entity) => System.Text.Json.JsonSerializer.Serialize(new + { + entity.Name, Date = entity.Date.ToUniversalTime(), entity.Selections, entity.UniqueGamesOnly, entity.WinnerActivityId + }); + [Function("UpdateEvent")] public async Task Update( [HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "events/{id:guid}")] HttpRequest req, diff --git a/MW-GC.EventManager.Tests/IdempotentCreateTests.cs b/MW-GC.EventManager.Tests/IdempotentCreateTests.cs new file mode 100644 index 0000000..00e80bb --- /dev/null +++ b/MW-GC.EventManager.Tests/IdempotentCreateTests.cs @@ -0,0 +1,248 @@ +using System.Collections.Concurrent; +using System.Linq.Expressions; +using System.Net; +using System.Net.Http.Json; +using System.Reflection; +using System.Text.Json; +using Azure; +using Azure.Data.Tables; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using MW_GC.EventManager.API.Functions; +using MW_GC.EventManager.API.Services; +using MW_GC.EventManager.Shared.Entities; +using MW_GC.EventManager.Shared.Models; +using MW_GC.EventManager.Web.Pages; +using MW_GC.EventManager.Web.Services; +using Xunit; + +namespace MW_GC.EventManager.Tests; + +public class IdempotentCreateTests +{ + private readonly ConcurrentDictionary rows = new(); + private readonly EventFunctions api; + private readonly EventEntity input; + private Func? beforeInsert; + + public IdempotentCreateTests() + { + var game = new Game { Name = "Game", Id = Guid.NewGuid() }; + input = new EventEntity + { + Name = "Retry me", Date = DateTimeOffset.Parse("2026-09-21T19:00:00Z"), + Selections = [new Selection { Game = game, Activity = new Activity { Id = Guid.NewGuid(), GameId = game.Id, Name = "Activity" } }] + }; + var table = new Mock(); + table.Setup(t => t.AddEntityAsync(It.IsAny(), It.IsAny())) + .Returns(async (TableEntity row, CancellationToken _) => + { + if (beforeInsert is not null) await beforeInsert(); + if (!rows.TryAdd(row.RowKey, new TableEntity(row))) throw new RequestFailedException(409, "Entity already exists", "EntityAlreadyExists", null); + return Mock.Of(); + }); + table.Setup(t => t.UpsertEntityAsync(It.IsAny(), TableUpdateMode.Replace, It.IsAny())) + .Callback((row, _, _) => rows[row.RowKey] = new TableEntity(row)) + .ReturnsAsync(Mock.Of()); + table.Setup(t => t.GetEntityAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync((string _, string id, IEnumerable _, CancellationToken _) => rows.TryGetValue(id, out var row) + ? Response.FromValue(row, Mock.Of()) : throw new RequestFailedException(404, "Missing")); + table.Setup(t => t.QueryAsync(It.IsAny>>(), It.IsAny(), It.IsAny>(), It.IsAny())) + .Returns(() => AsyncPageable.FromPages([Page.FromValues(rows.Values.ToList(), null, Mock.Of())])); + var service = new Mock(); + service.Setup(s => s.GetTableClient(It.IsAny())).Returns(table.Object); + api = new(new(service.Object, "events", "events"), new(service.Object, "games", "games"), new(service.Object, "activities", "activities"), new()); + } + + [Fact] + public async Task LostCreateResponseThenRepeatedRetryReturnsOnePersistedEvent() + { + var key = Guid.NewGuid().ToString("D"); + await api.SaveCustomized(Request(input, key), default); // Committed response never reaches client. + for (var retry = 0; retry < 3; retry++) + Assert.IsType(await api.SaveCustomized(Request(input, key), default)); + Assert.Equal(Guid.Parse(key), Assert.Single(await Listed()).Id); + } + + [Fact] + public async Task ConcurrentSameKeyInsertsReconcileWithoutOverwriting() + { + var arrivals = 0; + var barrier = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + beforeInsert = async () => + { + if (Interlocked.Increment(ref arrivals) == 2) barrier.SetResult(); + await barrier.Task.WaitAsync(TimeSpan.FromSeconds(10)); + }; + var key = Guid.NewGuid().ToString("D"); + var results = await Task.WhenAll(api.SaveCustomized(Request(input, key), default), api.SaveCustomized(Request(input, key), default)); + Assert.Single(results.OfType()); + Assert.Single(results.OfType()); + Assert.Equal(Guid.Parse(key), Assert.Single(await Listed()).Id); + } + + [Theory] + [InlineData("name")] + [InlineData("date")] + [InlineData("snapshot")] + [InlineData("unique")] + public async Task ChangedRetryConflictsInsteadOfDiscardingDetails(string change) + { + var key = Guid.NewGuid().ToString("D"); + await api.SaveCustomized(Request(input, key), default); + var before = JsonSerializer.Serialize(Assert.Single(await Listed())); + switch (change) + { + case "name": input.Name = "Changed"; break; + case "date": input.Date = input.Date.AddHours(1); break; + case "snapshot": input.Selections[0] = input.Selections[0] with { Activity = input.Selections[0].Activity with { Comments = "Changed" } }; break; + case "unique": input.UniqueGamesOnly = false; break; + } + Assert.IsType(await api.SaveCustomized(Request(input, key), default)); + Assert.Equal(before, JsonSerializer.Serialize(Assert.Single(await Listed()))); + } + + [Fact] + public async Task RetryIgnoresStorageMetadataButNeverOverwritesLaterUpdate() + { + var key = Guid.NewGuid().ToString("D"); + await api.SaveCustomized(Request(input, key), default); + rows[key].Timestamp = DateTimeOffset.UtcNow; + rows[key].ETag = new ETag("storage-version"); + input.Date = input.Date.ToOffset(TimeSpan.FromHours(3)); + Assert.IsType(await api.SaveCustomized(Request(input, key), default)); + var saved = Assert.Single(await Listed()); + saved.Name = "Later edit"; + Assert.IsType(await api.Update(Request(saved), saved.Id, default)); + Assert.IsType(await api.SaveCustomized(Request(input, key), default)); + Assert.Equal("Later edit", Assert.Single(await Listed()).Name); + } + + [Theory] + [InlineData("")] + [InlineData("bad-key")] + [InlineData("00000000-0000-0000-0000-000000000000")] + [InlineData("11111111111111111111111111111111")] + public async Task InvalidKeyDoesNotCreate(string key) + { + Assert.IsType(await api.SaveCustomized(Request(input, key), default)); + Assert.Empty(await Listed()); + } + + [Fact] + public async Task LegacyClientsStillCreateIndependentEvents() + { + await api.SaveCustomized(Request(input), default); + await api.SaveCustomized(Request(input), default); + Assert.Equal(2, (await Listed()).Select(e => e.Id).Distinct().Count()); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(true, true)] + [InlineData(true, false, true)] + public async Task DialogRetriesSameCreateAfterTransportFailure(bool committed, bool timeout, bool changeDetails = false) + { + var page = new Events(); + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + void Set(string name, object value) => typeof(Events).GetField(name, flags)!.SetValue(page, value); + object? Get(string name) => typeof(Events).GetField(name, flags)!.GetValue(page); + Task Save() => (Task)typeof(Events).GetMethod("SaveCustomizedEvent", flags)!.Invoke(page, null)!; + var selection = input.Selections[0]; + Set("_games", new List { new() { Id = selection.Game.Id, Name = selection.Game.Name } }); + Set("_activities", new List { new() { Id = selection.Activity.Id, GameId = selection.Game.Id, Name = selection.Activity.Name } }); + void NewDialog() + { + typeof(Events).GetMethod("ShowCustomize", flags)!.Invoke(page, null); + Set("_custName", input.Name); + } + var handler = new ApiHandler(api) { Failures = 2, CommitBeforeFailure = committed, Timeout = timeout }; + typeof(Events).GetProperty("EventSvc", flags)!.SetValue(page, + new EventService(new HttpClient(handler) { BaseAddress = new Uri("https://example.test") })); + NewDialog(); + await Save(); + await Save(); + Assert.Equal(true, Get("_showCustomize")); + Assert.NotNull(Get("_customizeError")); + Assert.Equal(committed ? 1 : 0, (await Listed()).Count); + if (changeDetails) + { + Set("_custName", "Changed after ambiguous save"); + await Save(); + Assert.Equal(true, Get("_showCustomize")); + Assert.Equal("Changed after ambiguous save", Get("_custName")); + Assert.Contains("different details", (string)Get("_customizeError")!); + Assert.Equal(input.Name, Assert.Single(await Listed()).Name); + Set("_custName", input.Name); + } + await Save(); + Assert.Equal(false, Get("_showCustomize")); + Assert.Null(Get("_customizeError")); + var saved = Assert.Single(await Listed()); + Assert.Equal(selection.Activity.Id, saved.WinnerActivityId); + Assert.All(handler.Keys, key => Assert.Equal(saved.Id.ToString("D"), key)); + Assert.Equal(saved.Id, Assert.Single((List)Get("_events")!).Id); + + typeof(Events).GetMethod("ShowEditEvent", flags)!.Invoke(page, [saved]); + Set("_custName", "Edited"); + await Save(); + Assert.Equal(HttpMethod.Put, handler.LastMethod); + Assert.Null(handler.LastKey); + Assert.Equal("Edited", Assert.Single(await Listed()).Name); + NewDialog(); + await Save(); + Assert.Equal(2, (await Listed()).Count); + Assert.NotEqual(saved.Id.ToString("D"), handler.LastKey); + } + + private sealed class ApiHandler(EventFunctions api) : HttpMessageHandler + { + public int Failures { get; set; } + public bool CommitBeforeFailure { get; init; } + public bool Timeout { get; init; } + public List Keys { get; } = []; + public string? LastKey { get; private set; } + public HttpMethod? LastMethod { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.Method == HttpMethod.Get) + { + var all = (OkObjectResult)await api.GetAll(new DefaultHttpContext().Request, cancellationToken); + return new(HttpStatusCode.OK) { Content = JsonContent.Create(all.Value) }; + } + LastMethod = request.Method; + LastKey = request.Headers.TryGetValues("Idempotency-Key", out var values) ? values.Single() : null; + if (request.Method == HttpMethod.Post) Keys.Add(LastKey); + var fail = Failures-- > 0; + void LoseResponse() + { + if (Timeout) throw new TaskCanceledException("Timed out after commit"); + throw new HttpRequestException("Connection lost"); + } + if (fail && !CommitBeforeFailure) LoseResponse(); + var entity = (await request.Content!.ReadFromJsonAsync(cancellationToken))!; + var result = (ObjectResult)(request.Method == HttpMethod.Post + ? await api.SaveCustomized(Request(entity, LastKey), cancellationToken) + : await api.Update(Request(entity), entity.Id, cancellationToken)); + if (fail) LoseResponse(); + return new((HttpStatusCode)result.StatusCode!) { Content = JsonContent.Create(result.Value) }; + } + } + + private static HttpRequest Request(EventEntity entity, string? key = null) + { + var context = new DefaultHttpContext(); + context.RequestServices = new ServiceCollection().AddOptions().BuildServiceProvider(); + context.Request.ContentType = "application/json"; + context.Request.Body = new MemoryStream(JsonSerializer.SerializeToUtf8Bytes(entity)); + if (key is not null) context.Request.Headers["Idempotency-Key"] = key; + return context.Request; + } + + private async Task> Listed() => Assert.IsType>( + Assert.IsType(await api.GetAll(Request(input), default)).Value); +} diff --git a/MW-GC.EventManager.Web/Pages/Events.razor b/MW-GC.EventManager.Web/Pages/Events.razor index 112a8c1..cb4d406 100644 --- a/MW-GC.EventManager.Web/Pages/Events.razor +++ b/MW-GC.EventManager.Web/Pages/Events.razor @@ -416,7 +416,7 @@ private string? _customizeError; private string? _pageError; private EventEntity? _editingEvent; - // Retain across failed saves; only a new create dialog starts a new operation. + // Retain across ambiguous failures and edits in this dialog; only a new create resets it. private Guid _createKey = Guid.NewGuid(); private string _custName = string.Empty; private DateTime? _custDate = DateTime.Today; @@ -726,7 +726,7 @@ if (saved) _pageError = "The event was saved, but the list could not be refreshed. Reload the page to see the saved event."; else - _customizeError = "Unable to contact the server. Please try again."; + _customizeError = "The save could not be confirmed. Retry in this dialog to avoid creating a duplicate. If you changed the details, reload the event list and check for the saved event first."; } finally { _generating = false; } } diff --git a/docs/idempotent-event-creates.md b/docs/idempotent-event-creates.md new file mode 100644 index 0000000..f86aeb6 --- /dev/null +++ b/docs/idempotent-event-creates.md @@ -0,0 +1,18 @@ +# Customized event create retries + +`POST /api/events` accepts an optional `Idempotency-Key` header: one non-empty GUID in hyphenated D format. The key becomes the event ID; the body ID is ignored. Deploy the API before the web client that relies on this header. An older API ignores the header and cannot prevent duplicate retries. + +The customize dialog generates a key for each intentionally new create and keeps it across failed/ambiguous saves, including edits made before retrying. Updates remain `PUT /api/events/{id}` without a create key. + +The API validates selections, normalizes the winner, then uses Azure Table Storage's atomic AddEntity operation, not a read-before-write check or upsert. A duplicate key returns the existing event with 200 when its normalized domain details match; the first insert returns 201. Different details return 409 with instructions to reload the list and edit the existing event. Retrying never overwrites a later update. Domain comparison includes name, date, selection snapshots, unique-game setting and winner, excluding storage metadata. Invalid headers return 400. Clients omitting the header retain legacy fresh-ID create behavior and do not gain deduplication. + +## Retention and recovery boundaries + +- Deduplication is backed by the event row, not a permanent request ledger. Deleting the row ends retention; a later retry can recreate it. +- The key lives in dialog memory. Reloading the browser, navigating away or opening a new create dialog does not resume the old operation. After an ambiguous save, retry in the same dialog or check the event list before intentionally starting another create. +- Changed retry details are not silently discarded or applied as an update. A 409 keeps the dialog and its edits visible; inspect the saved event and use its edit action. +- The generate endpoint and other entities are outside this change. + +## Regression coverage + +`IdempotentCreateTests` connects page handlers through the actual EventService and Functions handlers to TableStore with a mocked TableClient. It covers commit-then-lost-response, timeout, failure before commit, repeated retries, changed details, subsequent updates, independent creates, invalid keys, legacy clients, metadata and barrier-synchronized concurrent inserts. Public API reads verify persisted outcomes. This is not live Azure or browser-renderer coverage. From 2fd7075a64a5a7262d0429a3b40d4f0db3753010 Mon Sep 17 00:00:00 2001 From: "MWG Bender[bot]" <331880973+mwg-bender[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:17:30 +0000 Subject: [PATCH 6/6] docs(events): consolidate idempotency guidance --- docs/event-create-retries.md | 8 +------- docs/idempotent-event-creates.md | 10 +++++----- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/docs/event-create-retries.md b/docs/event-create-retries.md index 34fdce5..346965d 100644 --- a/docs/event-create-retries.md +++ b/docs/event-create-retries.md @@ -1,9 +1,3 @@ # Customized event create retries -`POST /api/events` accepts an optional `Idempotency-Key` header containing one non-empty GUID in `D` format. The key becomes the event ID; body IDs remain ignored. The UI generates a key for each create dialog and retains it across failed saves, including lost responses and timeouts. Updates still use PUT. - -The API uses Azure Table Storage's atomic insert, not upsert or a read-before-write check. An existing row cannot be replaced by a retry. An identical normalized payload returns the existing event (200); a new insert returns 201. If the stored details differ, the API returns 409 with instructions to reload and edit the saved event. Invalid keys return 400. Requests without a key retain legacy server-generated IDs and are not retry-safe. - -The event row itself is the deduplication record, so protection lasts while that row exists. This is not a durable request ledger: reopening the dialog or reloading the browser starts a new create, and replay after deletion can recreate the same ID. The generated-event endpoint is unchanged. Deploy the API support before the updated UI; an older API ignores the header. - -Tests exercise the actual API functions against a mocked Azure TableClient and the existing page-save handler seam against a deterministic HttpMessageHandler. They simulate lost HTTP responses after commit, failures before and after storage commit, and concurrent inserts using a barrier rather than timing. These are not live Azure or browser end-to-end tests. +This guide has moved to [Idempotent customized event creates](idempotent-event-creates.md). diff --git a/docs/idempotent-event-creates.md b/docs/idempotent-event-creates.md index f86aeb6..11d8579 100644 --- a/docs/idempotent-event-creates.md +++ b/docs/idempotent-event-creates.md @@ -1,8 +1,8 @@ -# Customized event create retries +# Idempotent customized event creates -`POST /api/events` accepts an optional `Idempotency-Key` header: one non-empty GUID in hyphenated D format. The key becomes the event ID; the body ID is ignored. Deploy the API before the web client that relies on this header. An older API ignores the header and cannot prevent duplicate retries. +`POST /api/events` accepts an optional `Idempotency-Key` header: one non-empty GUID in hyphenated D format. The key becomes the event ID; body IDs are ignored. Deploy the API before the web client that relies on this header. An older API ignores the header and cannot prevent duplicate retries. -The customize dialog generates a key for each intentionally new create and keeps it across failed/ambiguous saves, including edits made before retrying. Updates remain `PUT /api/events/{id}` without a create key. +The customize dialog generates a new key when opened for each intentionally new create and keeps it across failed/ambiguous saves, including lost responses, timeouts and edits made before retrying. Updates remain `PUT /api/events/{id}` without a create key. The API validates selections, normalizes the winner, then uses Azure Table Storage's atomic AddEntity operation, not a read-before-write check or upsert. A duplicate key returns the existing event with 200 when its normalized domain details match; the first insert returns 201. Different details return 409 with instructions to reload the list and edit the existing event. Retrying never overwrites a later update. Domain comparison includes name, date, selection snapshots, unique-game setting and winner, excluding storage metadata. Invalid headers return 400. Clients omitting the header retain legacy fresh-ID create behavior and do not gain deduplication. @@ -11,8 +11,8 @@ The API validates selections, normalizes the winner, then uses Azure Table Stora - Deduplication is backed by the event row, not a permanent request ledger. Deleting the row ends retention; a later retry can recreate it. - The key lives in dialog memory. Reloading the browser, navigating away or opening a new create dialog does not resume the old operation. After an ambiguous save, retry in the same dialog or check the event list before intentionally starting another create. - Changed retry details are not silently discarded or applied as an update. A 409 keeps the dialog and its edits visible; inspect the saved event and use its edit action. -- The generate endpoint and other entities are outside this change. +- The generated-event endpoint (`POST /api/events/generate`) and other entities are outside this change. ## Regression coverage -`IdempotentCreateTests` connects page handlers through the actual EventService and Functions handlers to TableStore with a mocked TableClient. It covers commit-then-lost-response, timeout, failure before commit, repeated retries, changed details, subsequent updates, independent creates, invalid keys, legacy clients, metadata and barrier-synchronized concurrent inserts. Public API reads verify persisted outcomes. This is not live Azure or browser-renderer coverage. +`IdempotentCreateTests` connects the existing page-save handler seam through the actual EventService and Functions handlers to TableStore with a mocked Azure TableClient. A deterministic HttpMessageHandler simulates failures before and after storage commit, including lost responses and timeouts. Tests also cover repeated retries, changed details, subsequent updates, independent creates, invalid keys, legacy clients, metadata and barrier-synchronized concurrent inserts (rather than timing-based concurrency). Public API reads verify persisted outcomes. This is not live Azure or browser-renderer coverage.