using System.Net; using System.Net.Http.Json; using ItWorx.KnowledgeHub.Contracts.Knowledge; using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure; namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge; /// Concurrent edits must produce an explicit conflict, never a silently lost /// update (docs/27-acceptance-matrix.md, M2-A05). [Collection(PostgresTestGroup.Name)] public sealed class PageConcurrencyTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres) { [Fact] public async Task Publishing_with_a_stale_ETag_is_rejected_and_never_overwrites_the_newer_revision() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var createResponse = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/pages", new CreatePageRequest(null, "Conflicttest", "WikiPage", "Origineel", null)); var page = await createResponse.Content.ReadFromJsonAsync(); Assert.NotNull(page); var staleETag = page.ETag; // Session A publishes successfully first, advancing the ETag. var firstEdit = await SetupHelper.PutWithCsrfAsync( client, $"/api/v1/pages/{page.Id}", new UpdatePageRequest("Titel van A", "Inhoud van A", "WikiPage", "Active", null), staleETag); Assert.Equal(HttpStatusCode.OK, firstEdit.StatusCode); // Session B still holds the original (now stale) ETag from before A's publish. var secondEdit = await SetupHelper.PutWithCsrfAsync( client, $"/api/v1/pages/{page.Id}", new UpdatePageRequest("Titel van B", "Inhoud van B", "WikiPage", "Active", null), staleETag); Assert.Equal(HttpStatusCode.PreconditionFailed, secondEdit.StatusCode); // The current server state is still A's edit, not silently overwritten by B. var current = await client.GetFromJsonAsync($"/api/v1/pages/{page.Id}"); Assert.NotNull(current); Assert.Equal("Titel van A", current.Title); } [Fact] public async Task Simultaneous_publishes_return_one_success_and_only_explicit_conflicts() { using var creator = Factory.CreateClient(); await SetupHelper.LoginAsync(creator, OwnerEmail, OwnerPassword); var createResponse = await SetupHelper.PostWithCsrfAsync( creator, "/api/v1/pages", new CreatePageRequest(null, "Gelijktijdige conflicttest", "WikiPage", "Origineel", null)); var page = await createResponse.Content.ReadFromJsonAsync(); Assert.NotNull(page); var clients = Enumerable.Range(0, 8).Select(_ => Factory.CreateClient()).ToArray(); try { await Task.WhenAll(clients.Select(client => SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword))); var responses = await Task.WhenAll(clients.Select((client, index) => SetupHelper.PutWithCsrfAsync( client, $"/api/v1/pages/{page.Id}", new UpdatePageRequest($"Kandidaat {index}", $"Inhoud {index}", "WikiPage", "Active", null), page.ETag))); Assert.Single(responses, response => response.StatusCode == HttpStatusCode.OK); Assert.Equal(7, responses.Count(response => response.StatusCode == HttpStatusCode.PreconditionFailed)); } finally { foreach (var client in clients) { client.Dispose(); } } var revisions = await creator.GetFromJsonAsync>($"/api/v1/pages/{page.Id}/revisions"); Assert.NotNull(revisions); Assert.Equal(2, revisions.Count); } }