Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817
This commit is contained in:
commit
ad8ad252a9
825 files changed
+124216
No files matched your search
@@ -0,0 +1,159 @@
|
||||
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<HttpClient> AuthenticatedClientAsync()
|
||||
{
|
||||
var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<DecisionDetail> 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<DecisionDetail>();
|
||||
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<ItWorx.KnowledgeHub.Contracts.Knowledge.PageDetail>();
|
||||
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<DecisionDetail>();
|
||||
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<DecisionDetail>();
|
||||
|
||||
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<DecisionDetail>();
|
||||
|
||||
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<DecisionDetail>();
|
||||
Assert.Equal("Accepted", acceptedReplacement!.Status);
|
||||
Assert.Equal(acceptedOriginal.Id, acceptedReplacement.Supersedes!.Id);
|
||||
|
||||
var originalAfter = await client.GetFromJsonAsync<DecisionDetail>($"/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<DecisionDetail>();
|
||||
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<DecisionListResponse>("/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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using ItWorx.KnowledgeHub.Contracts.Admin;
|
||||
using ItWorx.KnowledgeHub.Contracts.Auth;
|
||||
using ItWorx.KnowledgeHub.Contracts.Projects;
|
||||
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.Projects;
|
||||
|
||||
[Collection(PostgresTestGroup.Name)]
|
||||
public sealed class ProjectHubTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
|
||||
{
|
||||
private async Task<HttpClient> AuthenticatedClientAsync()
|
||||
{
|
||||
var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<ProjectDetail> CreateProjectAsync(HttpClient client, string name)
|
||||
{
|
||||
var response = await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/projects", new CreateProjectRequest(name, "Samenvatting", "Medium", "Startsituatie.", Guid.NewGuid()));
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var project = await response.Content.ReadFromJsonAsync<ProjectDetail>();
|
||||
Assert.NotNull(project);
|
||||
return project;
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseMessage> UploadProjectIconAsync(
|
||||
HttpClient client, Guid projectId, string eTag, byte[] bytes, string filename, string mediaType)
|
||||
{
|
||||
var csrf = await SetupHelper.GetCsrfTokenAsync(client);
|
||||
using var content = new MultipartFormDataContent();
|
||||
var image = new ByteArrayContent(bytes);
|
||||
image.Headers.ContentType = new MediaTypeHeaderValue(mediaType);
|
||||
content.Add(image, "file", filename);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/projects/{projectId}/icon") { Content = content };
|
||||
request.Headers.Add(csrf.HeaderName, csrf.Value);
|
||||
request.Headers.TryAddWithoutValidation("If-Match", $"\"{eTag}\"");
|
||||
return await client.SendAsync(request);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_project_can_be_created_and_read_back_with_its_default_lifecycle_status()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
|
||||
var project = await CreateProjectAsync(client, "Kennisbank migratie");
|
||||
|
||||
Assert.Equal("Concept", project.Status);
|
||||
Assert.Equal("kennisbank-migratie", project.Slug);
|
||||
Assert.Empty(project.Documents);
|
||||
Assert.Empty(project.RecentUpdates);
|
||||
Assert.Empty(project.Milestones);
|
||||
|
||||
var fetched = await client.GetFromJsonAsync<ProjectDetail>($"/api/v1/projects/{project.Id}");
|
||||
Assert.NotNull(fetched);
|
||||
Assert.Equal(project.Id, fetched.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_project_appears_in_the_workspace_project_list()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
var project = await CreateProjectAsync(client, "Zichtbaar project");
|
||||
|
||||
var list = await client.GetFromJsonAsync<ProjectListResponse>("/api/v1/projects");
|
||||
|
||||
Assert.NotNull(list);
|
||||
Assert.Contains(list.Items, item => item.Id == project.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_project_icon_is_uploaded_as_a_safe_attachment_and_returned_in_project_reads()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
var project = await CreateProjectAsync(client, "Project met logo");
|
||||
byte[] png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x01, 0x02, 0x03];
|
||||
|
||||
var upload = await UploadProjectIconAsync(client, project.Id, project.ETag, png, "logo.png", "image/png");
|
||||
Assert.Equal(HttpStatusCode.OK, upload.StatusCode);
|
||||
var icon = await upload.Content.ReadFromJsonAsync<ProjectIconResponse>();
|
||||
Assert.NotNull(icon);
|
||||
Assert.NotEqual(project.ETag, icon.ETag);
|
||||
Assert.Equal(icon.ETag, SetupHelper.GetETag(upload));
|
||||
|
||||
var detail = await client.GetFromJsonAsync<ProjectDetail>($"/api/v1/projects/{project.Id}");
|
||||
Assert.Equal(icon.IconAttachmentId, detail!.IconAttachmentId);
|
||||
var list = await client.GetFromJsonAsync<ProjectListResponse>("/api/v1/projects");
|
||||
Assert.Equal(icon.IconAttachmentId, list!.Items.Single(item => item.Id == project.Id).IconAttachmentId);
|
||||
|
||||
var content = await client.GetAsync($"/api/v1/attachments/{icon.IconAttachmentId}/content");
|
||||
Assert.Equal(HttpStatusCode.OK, content.StatusCode);
|
||||
Assert.Equal("image/png", content.Content.Headers.ContentType?.MediaType);
|
||||
Assert.Equal(png, await content.Content.ReadAsByteArrayAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Replacing_a_project_icon_retires_prior_content_and_bounds_project_icon_usages()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
var project = await CreateProjectAsync(client, "Roteerbaar projectlogo");
|
||||
byte[] firstPng = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x01];
|
||||
byte[] secondPng = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x02];
|
||||
byte[] thirdPng = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x03];
|
||||
|
||||
var firstResponse = await UploadProjectIconAsync(client, project.Id, project.ETag, firstPng, "first.png", "image/png");
|
||||
var first = await firstResponse.Content.ReadFromJsonAsync<ProjectIconResponse>();
|
||||
var secondResponse = await UploadProjectIconAsync(client, project.Id, first!.ETag, secondPng, "second.png", "image/png");
|
||||
var second = await secondResponse.Content.ReadFromJsonAsync<ProjectIconResponse>();
|
||||
var thirdResponse = await UploadProjectIconAsync(client, project.Id, second!.ETag, thirdPng, "third.png", "image/png");
|
||||
var third = await thirdResponse.Content.ReadFromJsonAsync<ProjectIconResponse>();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, thirdResponse.StatusCode);
|
||||
Assert.NotNull(third);
|
||||
Assert.Equal(third.IconAttachmentId, (await client.GetFromJsonAsync<ProjectDetail>($"/api/v1/projects/{project.Id}"))!.IconAttachmentId);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/v1/attachments/{first.IconAttachmentId}/content")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/v1/attachments/{second.IconAttachmentId}/content")).StatusCode);
|
||||
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ItWorx.KnowledgeHub.Infrastructure.Persistence.KnowledgeHubDbContext>();
|
||||
Assert.Equal(2, await db.AttachmentUsages.CountAsync(usage => usage.ProjectId == project.Id));
|
||||
Assert.False(await db.AttachmentReferences.AnyAsync(reference => reference.Id == first.IconAttachmentId));
|
||||
Assert.True(await db.AttachmentReferences.AnyAsync(reference => reference.Id == second.IconAttachmentId && reference.TrashedAt != null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Project_icon_upload_rejects_svg_and_files_above_two_mebibytes()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
var project = await CreateProjectAsync(client, "Veilig projectlogo");
|
||||
|
||||
var svg = await UploadProjectIconAsync(client, project.Id, project.ETag, "<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>"u8.ToArray(), "logo.svg", "image/svg+xml");
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, svg.StatusCode);
|
||||
|
||||
var tooLarge = await UploadProjectIconAsync(client, project.Id, project.ETag, new byte[(2 * 1024 * 1024) + 1], "large.png", "image/png");
|
||||
Assert.Equal(HttpStatusCode.RequestEntityTooLarge, tooLarge.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Project_catalog_filters_and_totals_are_server_authoritative()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
var matching = await CreateProjectAsync(client, "Catalogusdoel Alpha");
|
||||
_ = await CreateProjectAsync(client, "Andere werkruimte Alpha");
|
||||
|
||||
var filtered = await client.GetFromJsonAsync<ProjectListResponse>(
|
||||
"/api/v1/projects?search=Catalogusdoel&priority=Medium&limit=1");
|
||||
Assert.NotNull(filtered);
|
||||
Assert.Single(filtered.Items);
|
||||
Assert.Equal(matching.Id, filtered.Items[0].Id);
|
||||
|
||||
var totals = await client.GetFromJsonAsync<ProjectCatalogTotalsResponse>("/api/v1/projects/totals");
|
||||
Assert.NotNull(totals);
|
||||
Assert.True(totals.TotalCount >= 2);
|
||||
Assert.Equal(0, totals.ActiveCount);
|
||||
|
||||
// The list's own TotalCount answers a different question from /projects/totals:
|
||||
// how many projects the *current filters* match. With limit=1 it must still be
|
||||
// the filtered total, never the returned page length.
|
||||
Assert.Equal(1, filtered.TotalCount);
|
||||
|
||||
var unfiltered = await client.GetFromJsonAsync<ProjectListResponse>("/api/v1/projects?limit=1");
|
||||
Assert.NotNull(unfiltered);
|
||||
Assert.Single(unfiltered.Items);
|
||||
Assert.Equal(totals.TotalCount, unfiltered.TotalCount);
|
||||
|
||||
// And it survives a change of sort order, which uses a different cursor scheme.
|
||||
var byRecency = await client.GetFromJsonAsync<ProjectListResponse>("/api/v1/projects?sort=updatedAt&limit=1");
|
||||
Assert.NotNull(byRecency);
|
||||
Assert.Equal(totals.TotalCount, byRecency.TotalCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Updating_a_project_with_the_current_ETag_succeeds_and_returns_a_new_ETag()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
var project = await CreateProjectAsync(client, "Bewerkbaar project");
|
||||
|
||||
var response = await SetupHelper.PutWithCsrfAsync(
|
||||
client,
|
||||
$"/api/v1/projects/{project.Id}",
|
||||
new UpdateProjectRequest(
|
||||
"Bijgewerkte naam", "Nieuwe samenvatting", "High", "Bijgewerkte stand.", project.OwnerId,
|
||||
null, "2026-12-01", null, "https://example.test/repo", null, null, null, ["C#", "React"]),
|
||||
project.ETag);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var updated = await response.Content.ReadFromJsonAsync<ProjectDetail>();
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal("Bijgewerkte naam", updated.Name);
|
||||
Assert.Equal("High", updated.Priority);
|
||||
Assert.Equal(["C#", "React"], updated.Technologies);
|
||||
Assert.NotEqual(project.ETag, updated.ETag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Updating_a_project_with_a_stale_ETag_is_rejected_as_a_conflict()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
var project = await CreateProjectAsync(client, "Conflictproject");
|
||||
|
||||
await SetupHelper.PutWithCsrfAsync(
|
||||
client,
|
||||
$"/api/v1/projects/{project.Id}",
|
||||
new UpdateProjectRequest("Eerste update", project.Summary, project.Priority, project.CurrentStateMarkdown, project.OwnerId, null, null, null, null, null, null, null, null),
|
||||
project.ETag);
|
||||
|
||||
var staleResponse = await SetupHelper.PutWithCsrfAsync(
|
||||
client,
|
||||
$"/api/v1/projects/{project.Id}",
|
||||
new UpdateProjectRequest("Tweede update", project.Summary, project.Priority, project.CurrentStateMarkdown, project.OwnerId, null, null, null, null, null, null, null, null),
|
||||
project.ETag);
|
||||
|
||||
Assert.Equal(HttpStatusCode.PreconditionFailed, staleResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Planned", true)]
|
||||
[InlineData("Active", false)]
|
||||
public async Task Project_status_transitions_follow_the_documented_lifecycle(string newStatus, bool expectSuccess)
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
var project = await CreateProjectAsync(client, $"Statusproject {newStatus}");
|
||||
|
||||
var response = await SetupHelper.PostWithCsrfAndIfMatchAsync(
|
||||
client, $"/api/v1/projects/{project.Id}/status", new ChangeProjectStatusRequest(newStatus), project.ETag);
|
||||
|
||||
if (expectSuccess)
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var updated = await response.Content.ReadFromJsonAsync<ProjectDetail>();
|
||||
Assert.Equal(newStatus, updated!.Status);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_page_can_be_linked_to_a_project_with_a_role_and_later_unlinked()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
var project = await CreateProjectAsync(client, "Documentproject");
|
||||
|
||||
var pageResponse = await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/pages", new ItWorx.KnowledgeHub.Contracts.Knowledge.CreatePageRequest(null, "Architectuuroverzicht", "WikiPage", "# Architectuur", null));
|
||||
var page = await pageResponse.Content.ReadFromJsonAsync<ItWorx.KnowledgeHub.Contracts.Knowledge.PageDetail>();
|
||||
Assert.NotNull(page);
|
||||
|
||||
var linkResponse = await SetupHelper.PostWithCsrfAsync(
|
||||
client, $"/api/v1/projects/{project.Id}/documents", new LinkProjectDocumentRequest(page.Id, "Architecture"));
|
||||
Assert.Equal(HttpStatusCode.OK, linkResponse.StatusCode);
|
||||
var link = await linkResponse.Content.ReadFromJsonAsync<ProjectDocumentSummary>();
|
||||
Assert.NotNull(link);
|
||||
|
||||
var detail = await client.GetFromJsonAsync<ProjectDetail>($"/api/v1/projects/{project.Id}");
|
||||
Assert.Contains(detail!.Documents, document => document.PageId == page.Id && document.Role == "Architecture");
|
||||
|
||||
var unlinkResponse = await SetupHelper.DeleteWithCsrfAsync(client, $"/api/v1/projects/documents/{link.Id}");
|
||||
Assert.Equal(HttpStatusCode.OK, unlinkResponse.StatusCode);
|
||||
|
||||
var afterUnlink = await client.GetFromJsonAsync<ProjectDetail>($"/api/v1/projects/{project.Id}");
|
||||
Assert.DoesNotContain(afterUnlink!.Documents, document => document.PageId == page.Id);
|
||||
|
||||
// The page itself must never be touched by unlinking (docs/15-project-hub.md).
|
||||
var pageStillExists = await client.GetAsync($"/api/v1/pages/{page.Id}");
|
||||
Assert.Equal(HttpStatusCode.OK, pageStillExists.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Project_updates_form_an_ordered_timeline_and_a_correction_supersedes_the_original()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
var project = await CreateProjectAsync(client, "Tijdlijnproject");
|
||||
|
||||
var firstResponse = await SetupHelper.PostWithCsrfAsync(
|
||||
client, $"/api/v1/projects/{project.Id}/updates",
|
||||
new CreateProjectUpdateRequest("Progress", "Eerste update", "Voortgang.", "Reported", null, null, null, null, null, null, null, null, null, null));
|
||||
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
|
||||
var first = await firstResponse.Content.ReadFromJsonAsync<ProjectUpdateSummary>();
|
||||
Assert.NotNull(first);
|
||||
|
||||
var secondResponse = await SetupHelper.PostWithCsrfAsync(
|
||||
client, $"/api/v1/projects/{project.Id}/updates",
|
||||
new CreateProjectUpdateRequest("Progress", "Tweede update", "Meer voortgang.", "Verified", null, null, null, null, null, null, null, null, null, null));
|
||||
Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode);
|
||||
|
||||
var correctionResponse = await SetupHelper.PostWithCsrfAsync(
|
||||
client, $"/api/v1/projects/{project.Id}/updates",
|
||||
new CreateProjectUpdateRequest(
|
||||
"Progress", "Gecorrigeerde eerste update", "Correctie.", "Verified", null, null, null, null, null,
|
||||
null, null, null, null, first.Id));
|
||||
Assert.Equal(HttpStatusCode.OK, correctionResponse.StatusCode);
|
||||
|
||||
var timeline = await client.GetFromJsonAsync<TimelineEnvelope>($"/api/v1/projects/{project.Id}/updates");
|
||||
Assert.NotNull(timeline);
|
||||
Assert.Equal(3, timeline.Items.Count);
|
||||
|
||||
var originalAfterCorrection = timeline.Items.Single(update => update.Id == first.Id);
|
||||
Assert.Equal("Superseded", originalAfterCorrection.VerificationStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Project_milestones_can_be_created_and_updated_with_ETag_concurrency()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
var project = await CreateProjectAsync(client, "Mijlpalenproject");
|
||||
|
||||
var createResponse = await SetupHelper.PostWithCsrfAsync(
|
||||
client, $"/api/v1/projects/{project.Id}/milestones",
|
||||
new CreateProjectMilestoneRequest("Eerste mijlpaal", "2026-09-01", "Doel.", "Criteria."));
|
||||
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
|
||||
var milestone = await createResponse.Content.ReadFromJsonAsync<ProjectMilestoneSummary>();
|
||||
Assert.NotNull(milestone);
|
||||
Assert.Equal("Planned", milestone.State);
|
||||
|
||||
var updateResponse = await SetupHelper.PutWithCsrfAsync(
|
||||
client, $"/api/v1/projects/milestones/{milestone.Id}",
|
||||
new UpdateProjectMilestoneRequest("Eerste mijlpaal", "2026-09-01", "Doel.", "Criteria.", "Completed"),
|
||||
milestone.ETag);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
|
||||
var updated = await updateResponse.Content.ReadFromJsonAsync<ProjectMilestoneSummary>();
|
||||
Assert.Equal("Completed", updated!.State);
|
||||
|
||||
var milestones = await client.GetFromJsonAsync<IReadOnlyList<ProjectMilestoneSummary>>($"/api/v1/projects/{project.Id}/milestones");
|
||||
Assert.Contains(milestones!, item => item.Id == milestone.Id && item.State == "Completed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_reader_cannot_create_or_mutate_projects_but_can_read_them()
|
||||
{
|
||||
using var adminClient = await AuthenticatedClientAsync();
|
||||
var project = await CreateProjectAsync(adminClient, "Alleen-lezenproject");
|
||||
|
||||
const string readerEmail = "projectlezer@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/projects/{project.Id}");
|
||||
Assert.Equal(HttpStatusCode.OK, readResponse.StatusCode);
|
||||
|
||||
var createResponse = await SetupHelper.PostWithCsrfAsync(
|
||||
readerClient, "/api/v1/projects", new CreateProjectRequest("Ongeautoriseerd project", "S", "Medium", "S", Guid.NewGuid()));
|
||||
Assert.Equal(HttpStatusCode.Forbidden, createResponse.StatusCode);
|
||||
}
|
||||
|
||||
private sealed record TimelineEnvelope(IReadOnlyList<ProjectUpdateSummary> Items, string? NextCursor);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using ItWorx.KnowledgeHub.Contracts.Admin;
|
||||
using ItWorx.KnowledgeHub.Contracts.Auth;
|
||||
using ItWorx.KnowledgeHub.Contracts.Runbooks;
|
||||
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.Projects;
|
||||
|
||||
[Collection(PostgresTestGroup.Name)]
|
||||
public sealed class RunbookTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
|
||||
{
|
||||
private async Task<HttpClient> AuthenticatedClientAsync()
|
||||
{
|
||||
var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<RunbookDetail> CreateRunbookAsync(HttpClient client, string title)
|
||||
{
|
||||
var response = await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/runbooks",
|
||||
new CreateRunbookRequest(null, title, "## Doel\n\nHerstel de dienst.", null, "High", true, 45));
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var runbook = await response.Content.ReadFromJsonAsync<RunbookDetail>();
|
||||
Assert.NotNull(runbook);
|
||||
return runbook;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Creating_a_runbook_creates_a_backing_Runbook_page_with_a_profile_and_is_stale_until_validated()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
|
||||
var runbook = await CreateRunbookAsync(client, "Databaseherstel");
|
||||
|
||||
Assert.Equal("Draft", runbook.Status);
|
||||
Assert.Equal("High", runbook.RiskLevel);
|
||||
Assert.True(runbook.IsStale);
|
||||
Assert.Null(runbook.LastValidatedAt);
|
||||
|
||||
var pageResponse = await client.GetAsync($"/api/v1/pages/{runbook.PageId}");
|
||||
var page = await pageResponse.Content.ReadFromJsonAsync<ItWorx.KnowledgeHub.Contracts.Knowledge.PageDetail>();
|
||||
Assert.Equal("Runbook", page!.DocumentType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validating_a_runbook_against_its_current_revision_clears_staleness()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
var runbook = await CreateRunbookAsync(client, "Te valideren procedure");
|
||||
|
||||
var response = await SetupHelper.PostWithCsrfAndIfMatchAsync(
|
||||
client, $"/api/v1/runbooks/{runbook.Id}/validate", new { }, runbook.ETag);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var validated = await response.Content.ReadFromJsonAsync<RunbookDetail>();
|
||||
Assert.False(validated!.IsStale);
|
||||
Assert.NotNull(validated.LastValidatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Editing_the_underlying_page_after_validation_makes_the_runbook_stale_again()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
var runbook = await CreateRunbookAsync(client, "Procedure die verandert");
|
||||
|
||||
var validateResponse = await SetupHelper.PostWithCsrfAndIfMatchAsync(
|
||||
client, $"/api/v1/runbooks/{runbook.Id}/validate", new { }, runbook.ETag);
|
||||
var validated = await validateResponse.Content.ReadFromJsonAsync<RunbookDetail>();
|
||||
Assert.False(validated!.IsStale);
|
||||
|
||||
var pageResponse = await client.GetFromJsonAsync<ItWorx.KnowledgeHub.Contracts.Knowledge.PageDetail>($"/api/v1/pages/{runbook.PageId}");
|
||||
await SetupHelper.PutWithCsrfAsync(
|
||||
client,
|
||||
$"/api/v1/pages/{runbook.PageId}",
|
||||
new ItWorx.KnowledgeHub.Contracts.Knowledge.UpdatePageRequest(
|
||||
pageResponse!.Title, "## Doel\n\nBijgewerkte procedure.", "Runbook", "Active", "Procedure bijgewerkt."),
|
||||
pageResponse.ETag);
|
||||
|
||||
var afterEdit = await client.GetFromJsonAsync<RunbookDetail>($"/api/v1/runbooks/{runbook.Id}");
|
||||
Assert.True(afterEdit!.IsStale);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Updating_the_runbook_profile_changes_risk_level_and_status()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
var runbook = await CreateRunbookAsync(client, "Aan te passen profiel");
|
||||
|
||||
var response = await SetupHelper.PutWithCsrfAsync(
|
||||
client, $"/api/v1/runbooks/{runbook.Id}/profile",
|
||||
new UpdateRunbookProfileRequest(runbook.Owner, "Critical", "Active", true, 90, null),
|
||||
runbook.ETag);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var updated = await response.Content.ReadFromJsonAsync<RunbookDetail>();
|
||||
Assert.Equal("Critical", updated!.RiskLevel);
|
||||
Assert.Equal("Active", updated.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Runbooks_appear_in_the_workspace_runbook_list()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
var runbook = await CreateRunbookAsync(client, "Vindbare procedure");
|
||||
|
||||
var list = await client.GetFromJsonAsync<RunbookListResponse>("/api/v1/runbooks");
|
||||
|
||||
Assert.NotNull(list);
|
||||
Assert.Contains(list.Items, item => item.Id == runbook.Id && item.Title == "Vindbare procedure");
|
||||
}
|
||||
|
||||
// Reuses the exact reader-boundary pattern already established in
|
||||
// ProjectHubTests.A_reader_cannot_create_or_mutate_projects_but_can_read_them —
|
||||
// see DecisionTests' identical addition for the same V2-M5 rationale.
|
||||
[Fact]
|
||||
public async Task A_reader_can_read_runbooks_but_cannot_create_or_validate_them()
|
||||
{
|
||||
using var adminClient = await AuthenticatedClientAsync();
|
||||
var runbook = await CreateRunbookAsync(adminClient, "Alleen-lezenprocedure");
|
||||
|
||||
const string readerEmail = "runbooklezer@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/runbooks/{runbook.Id}");
|
||||
Assert.Equal(HttpStatusCode.OK, readResponse.StatusCode);
|
||||
|
||||
var createResponse = await SetupHelper.PostWithCsrfAsync(
|
||||
readerClient, "/api/v1/runbooks",
|
||||
new CreateRunbookRequest(null, "Ongeautoriseerde procedure", "## Doel\n\nX.", null, "Low", false, null));
|
||||
Assert.Equal(HttpStatusCode.Forbidden, createResponse.StatusCode);
|
||||
|
||||
var validateResponse = await SetupHelper.PostWithCsrfAndIfMatchAsync(
|
||||
readerClient, $"/api/v1/runbooks/{runbook.Id}/validate", new { }, runbook.ETag);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, validateResponse.StatusCode);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user