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..78cdd7e 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); @@ -83,6 +86,7 @@ public async Task Generate( UniqueGamesOnly = request.UniqueGamesOnly }; + entity.NormalizeWinner(); await _store.UpsertAsync(entity, ct); return new CreatedResult($"/api/events/{entity.Id}", entity); } @@ -92,14 +96,35 @@ 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(); - await _store.UpsertAsync(entity, ct); + 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 = 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(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, @@ -108,14 +133,28 @@ 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(); await _store.UpsertAsync(entity, ct); 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/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.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.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.Shared/Entities/EventEntity.cs b/MW-GC.EventManager.Shared/Entities/EventEntity.cs index 877af2f..3d99ab7 100644 --- a/MW-GC.EventManager.Shared/Entities/EventEntity.cs +++ b/MW-GC.EventManager.Shared/Entities/EventEntity.cs @@ -15,4 +15,31 @@ 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() + { + 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/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.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..d690a49 --- /dev/null +++ b/MW-GC.EventManager.Tests/SingleActivityApiTests.cs @@ -0,0 +1,481 @@ +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 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; + 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.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())) + .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(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(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()); + } + + private EventEntity Event(params Activity[] activities) => new() + { + 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() + }; + + 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); + + 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); + } + } + + [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. + 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)] + 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); + } + + [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); + 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; + saved = await Update(saved); + saved = await Read(saved.Id); + Assert.Equal(replacement.Id, saved.WinnerActivityId); + saved.Selections.AddRange(Event(activity).Selections); + 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); + saved = await Update(saved); + 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); + } + + [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)] + 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)); + } + + [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() + { + 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..75a29fb 100644 --- a/MW-GC.EventManager.Tests/SlotRerollTests.cs +++ b/MW-GC.EventManager.Tests/SlotRerollTests.cs @@ -146,6 +146,318 @@ 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()); + 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)!; + 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()); + } + + [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); + } + + [Theory] + [InlineData(null)] + [InlineData("{")] + [InlineData("[{\"id\":\"not-a-guid\"}]")] + public async Task SuccessfulSaveWithFailedReadBackDoesNotOfferCreateRetry(string? readJson) + { + Call("RemoveSlot", 1); + var handler = ConfigureSave(); + handler.FailRead = readJson is null; + handler.ReadJson = readJson; + 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); + } + + [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"); + 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 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; } + 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"); + 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"); + 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) }; + } + } 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..cb4d406 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) @@ -168,6 +172,11 @@ 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 (_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 @@ -239,7 +248,7 @@ @@ -257,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; @@ -396,7 +413,11 @@ // Customize dialog state private bool _showCustomize; private bool _generating; + private string? _customizeError; + private string? _pageError; private EventEntity? _editingEvent; + // 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; private DateTime? _custTime = DateTime.Today.AddHours(19); @@ -454,7 +475,9 @@ private void ShowCustomize() { + _customizeError = null; _editingEvent = null; + _createKey = Guid.NewGuid(); _custName = string.Empty; _custDate = DateTime.Today; _custTime = DateTime.Today.AddHours(19); @@ -470,6 +493,7 @@ private void ShowEditEvent(EventEntity e) { + _customizeError = null; _editingEvent = e; _custName = e.Name; var localStart = e.Date.ToLocalTime().DateTime; @@ -612,7 +636,7 @@ private void RemoveSlot(int index) { - if (_custSelections.Count <= 2) return; + if (_custSelections.Count <= 1) return; _custSelections.RemoveAt(index); } @@ -629,11 +653,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); @@ -659,14 +703,31 @@ WinnerActivityId = _editingEvent?.WinnerActivityId }; - if (_editingEvent is not null) - await EventSvc.UpdateAsync(entity); - else - await EventSvc.SaveAsync(entity); + entity.NormalizeWinner(); + using var response = _editingEvent is not null + ? await EventSvc.UpdateAsync(entity) + : await EventSvc.SaveAsync(entity, _createKey); + 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 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."; + else + _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/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/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. diff --git a/docs/event-create-retries.md b/docs/event-create-retries.md new file mode 100644 index 0000000..346965d --- /dev/null +++ b/docs/event-create-retries.md @@ -0,0 +1,3 @@ +# Customized event create retries + +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 new file mode 100644 index 0000000..11d8579 --- /dev/null +++ b/docs/idempotent-event-creates.md @@ -0,0 +1,18 @@ +# 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; 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 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. + +## 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 generated-event endpoint (`POST /api/events/generate`) and other entities are outside this change. + +## Regression 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.