Files

363 lines
18 KiB
C#

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);
}