using System.Net; using System.Net.Http.Json; using ItWorx.KnowledgeHub.Contracts.Admin; using ItWorx.KnowledgeHub.Contracts.Auth; using ItWorx.KnowledgeHub.Contracts.Decisions; using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure; namespace ItWorx.KnowledgeHub.IntegrationTests.Projects; [Collection(PostgresTestGroup.Name)] public sealed class DecisionTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres) { private async Task AuthenticatedClientAsync() { var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); return client; } private static async Task CreateDecisionAsync(HttpClient client, string title, Guid? supersedes = null) { var response = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/decisions", new CreateDecisionRequest(null, null, title, "## Context\n\nEen besluit.", "Samenvatting van het besluit.", supersedes)); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var decision = await response.Content.ReadFromJsonAsync(); Assert.NotNull(decision); return decision; } [Fact] public async Task Creating_a_decision_creates_a_backing_ArchitectureDecision_page_with_a_profile() { using var client = await AuthenticatedClientAsync(); var decision = await CreateDecisionAsync(client, "Gebruik PostgreSQL als opslag"); Assert.Equal("Proposed", decision.Status); Assert.Equal(1, decision.DecisionNumber); Assert.Equal("Gebruik PostgreSQL als opslag", decision.Title); var pageResponse = await client.GetAsync($"/api/v1/pages/{decision.PageId}"); Assert.Equal(HttpStatusCode.OK, pageResponse.StatusCode); var page = await pageResponse.Content.ReadFromJsonAsync(); Assert.Equal("ArchitectureDecision", page!.DocumentType); } [Fact] public async Task Accepting_a_proposed_decision_transitions_its_status() { using var client = await AuthenticatedClientAsync(); var decision = await CreateDecisionAsync(client, "Gebruik trigger-based search_vector"); var response = await SetupHelper.PostWithCsrfAndIfMatchAsync( client, $"/api/v1/decisions/{decision.Id}/accept", new { }, decision.ETag); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var accepted = await response.Content.ReadFromJsonAsync(); Assert.Equal("Accepted", accepted!.Status); } [Fact] public async Task Rejecting_an_already_accepted_decision_is_an_invalid_transition() { using var client = await AuthenticatedClientAsync(); var decision = await CreateDecisionAsync(client, "Al geaccepteerd besluit"); var acceptResponse = await SetupHelper.PostWithCsrfAndIfMatchAsync( client, $"/api/v1/decisions/{decision.Id}/accept", new { }, decision.ETag); var accepted = await acceptResponse.Content.ReadFromJsonAsync(); var rejectResponse = await SetupHelper.PostWithCsrfAndIfMatchAsync( client, $"/api/v1/decisions/{decision.Id}/reject", new { }, accepted!.ETag); Assert.Equal(HttpStatusCode.Conflict, rejectResponse.StatusCode); } [Fact] public async Task Accepting_a_decision_that_supersedes_an_accepted_one_flips_the_original_to_superseded_with_bidirectional_links() { using var client = await AuthenticatedClientAsync(); var original = await CreateDecisionAsync(client, "Oorspronkelijk besluit"); var acceptOriginalResponse = await SetupHelper.PostWithCsrfAndIfMatchAsync( client, $"/api/v1/decisions/{original.Id}/accept", new { }, original.ETag); var acceptedOriginal = await acceptOriginalResponse.Content.ReadFromJsonAsync(); var replacement = await CreateDecisionAsync(client, "Vervangend besluit", acceptedOriginal!.Id); var acceptReplacementResponse = await SetupHelper.PostWithCsrfAndIfMatchAsync( client, $"/api/v1/decisions/{replacement.Id}/accept", new { }, replacement.ETag); Assert.Equal(HttpStatusCode.OK, acceptReplacementResponse.StatusCode); var acceptedReplacement = await acceptReplacementResponse.Content.ReadFromJsonAsync(); Assert.Equal("Accepted", acceptedReplacement!.Status); Assert.Equal(acceptedOriginal.Id, acceptedReplacement.Supersedes!.Id); var originalAfter = await client.GetFromJsonAsync($"/api/v1/decisions/{original.Id}"); Assert.Equal("Superseded", originalAfter!.Status); Assert.Equal(replacement.Id, originalAfter.SupersededBy!.Id); } [Fact] public async Task Withdrawing_a_proposed_decision_transitions_its_status() { using var client = await AuthenticatedClientAsync(); var decision = await CreateDecisionAsync(client, "In te trekken besluit"); var response = await SetupHelper.PostWithCsrfAndIfMatchAsync( client, $"/api/v1/decisions/{decision.Id}/withdraw", new { }, decision.ETag); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var withdrawn = await response.Content.ReadFromJsonAsync(); Assert.Equal("Withdrawn", withdrawn!.Status); } [Fact] public async Task Decisions_appear_in_the_workspace_decision_list() { using var client = await AuthenticatedClientAsync(); var decision = await CreateDecisionAsync(client, "Vindbaar besluit"); var list = await client.GetFromJsonAsync("/api/v1/decisions"); Assert.NotNull(list); Assert.Contains(list.Items, item => item.Id == decision.Id && item.Title == "Vindbaar besluit"); } // Reuses the exact reader-boundary pattern already established in // ProjectHubTests.A_reader_cannot_create_or_mutate_projects_but_can_read_them — // V2-M5 found this same boundary was left untested for decisions/runbooks even // though the endpoints themselves (DecisionsEndpoints.cs) already correctly // require Editor for create/accept/reject/withdraw. [Fact] public async Task A_reader_can_read_decisions_but_cannot_create_or_transition_them() { using var adminClient = await AuthenticatedClientAsync(); var decision = await CreateDecisionAsync(adminClient, "Alleen-lezenbesluit"); const string readerEmail = "besluitlezer@itworx.test"; const string readerPassword = "reader-strong-password"; var createUserResponse = await SetupHelper.PostWithCsrfAsync( adminClient, "/api/v1/admin/users", new CreateUserRequest("Testlezer", readerEmail, readerPassword, "Reader")); Assert.Equal(HttpStatusCode.OK, createUserResponse.StatusCode); using var readerClient = Factory.CreateClient(); await SetupHelper.LoginAsync(readerClient, readerEmail, readerPassword); var readResponse = await readerClient.GetAsync($"/api/v1/decisions/{decision.Id}"); Assert.Equal(HttpStatusCode.OK, readResponse.StatusCode); var createResponse = await SetupHelper.PostWithCsrfAsync( readerClient, "/api/v1/decisions", new CreateDecisionRequest(null, null, "Ongeautoriseerd besluit", null, "S", null)); Assert.Equal(HttpStatusCode.Forbidden, createResponse.StatusCode); var acceptResponse = await SetupHelper.PostWithCsrfAndIfMatchAsync( readerClient, $"/api/v1/decisions/{decision.Id}/accept", new { }, decision.ETag); Assert.Equal(HttpStatusCode.Forbidden, acceptResponse.StatusCode); } }