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 AuthenticatedClientAsync() { var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); return client; } private static async Task 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(); Assert.NotNull(project); return project; } private static async Task 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($"/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("/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(); Assert.NotNull(icon); Assert.NotEqual(project.ETag, icon.ETag); Assert.Equal(icon.ETag, SetupHelper.GetETag(upload)); var detail = await client.GetFromJsonAsync($"/api/v1/projects/{project.Id}"); Assert.Equal(icon.IconAttachmentId, detail!.IconAttachmentId); var list = await client.GetFromJsonAsync("/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(); var secondResponse = await UploadProjectIconAsync(client, project.Id, first!.ETag, secondPng, "second.png", "image/png"); var second = await secondResponse.Content.ReadFromJsonAsync(); var thirdResponse = await UploadProjectIconAsync(client, project.Id, second!.ETag, thirdPng, "third.png", "image/png"); var third = await thirdResponse.Content.ReadFromJsonAsync(); 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($"/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(); 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, ""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( "/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("/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("/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("/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(); 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(); 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(); 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(); Assert.NotNull(link); var detail = await client.GetFromJsonAsync($"/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($"/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(); 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($"/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(); 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(); Assert.Equal("Completed", updated!.State); var milestones = await client.GetFromJsonAsync>($"/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 Items, string? NextCursor); }