363 lines
18 KiB
C#
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);
|
|
}
|