Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:37:37 +02:00
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);
}
}