using System.Net; using System.Net.Http.Json; using ItWorx.KnowledgeHub.Contracts.Projects; using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure; namespace ItWorx.KnowledgeHub.IntegrationTests.Automation; /// A retried create/append request carrying the same /// Idempotency-Key must never execute the mutation twice /// (docs/09-api-contract.md). [Collection(PostgresTestGroup.Name)] public sealed class IdempotencyTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres) { [Fact] public async Task Retrying_a_project_update_with_the_same_Idempotency_Key_does_not_create_a_second_update() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var projectResponse = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/projects", new CreateProjectRequest("Idempotentieproject", "S.", "Medium", "Start.", Guid.NewGuid())); var project = await projectResponse.Content.ReadFromJsonAsync(); Assert.NotNull(project); var idempotencyKey = Guid.NewGuid().ToString(); var request = new CreateProjectUpdateRequest( "Progress", "Idempotente update", "Samenvatting.", "Reported", null, null, null, null, null, null, null, null, null, null); var first = await PostWithIdempotencyKeyAsync(client, $"/api/v1/projects/{project.Id}/updates", request, idempotencyKey); Assert.Equal(HttpStatusCode.OK, first.StatusCode); var firstBody = await first.Content.ReadFromJsonAsync(); var second = await PostWithIdempotencyKeyAsync(client, $"/api/v1/projects/{project.Id}/updates", request, idempotencyKey); Assert.Equal(HttpStatusCode.OK, second.StatusCode); var secondBody = await second.Content.ReadFromJsonAsync(); // The replayed response is the exact cached original — same update ID — not a // freshly created second row. Assert.Equal(firstBody!.Id, secondBody!.Id); var timeline = await client.GetFromJsonAsync($"/api/v1/projects/{project.Id}/updates"); Assert.NotNull(timeline); Assert.Single(timeline.Items, update => update.Title == "Idempotente update"); } [Fact] public async Task Reusing_an_Idempotency_Key_with_different_request_content_is_a_conflict() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var projectResponse = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/projects", new CreateProjectRequest("Conflictproject idempotentie", "S.", "Medium", "Start.", Guid.NewGuid())); var project = await projectResponse.Content.ReadFromJsonAsync(); Assert.NotNull(project); var idempotencyKey = Guid.NewGuid().ToString(); var first = new CreateProjectUpdateRequest( "Progress", "Eerste inhoud", "S.", "Reported", null, null, null, null, null, null, null, null, null, null); var different = new CreateProjectUpdateRequest( "Progress", "Andere inhoud", "S.", "Reported", null, null, null, null, null, null, null, null, null, null); var firstResponse = await PostWithIdempotencyKeyAsync(client, $"/api/v1/projects/{project.Id}/updates", first, idempotencyKey); Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode); var conflictResponse = await PostWithIdempotencyKeyAsync(client, $"/api/v1/projects/{project.Id}/updates", different, idempotencyKey); Assert.Equal(HttpStatusCode.Conflict, conflictResponse.StatusCode); } private static async Task PostWithIdempotencyKeyAsync( HttpClient client, string path, TBody body, string idempotencyKey) { var csrf = await SetupHelper.GetCsrfTokenAsync(client); using var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = JsonContent.Create(body) }; request.Headers.Add(csrf.HeaderName, csrf.Value); request.Headers.Add("Idempotency-Key", idempotencyKey); return await client.SendAsync(request); } private sealed record ProjectUpdatesEnvelope(IReadOnlyList Items, string? NextCursor); }