using System.Net; using System.Text.Json; using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure; namespace ItWorx.KnowledgeHub.IntegrationTests.Platform; [Collection(PostgresTestGroup.Name)] public sealed class UserPreferencesApiTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres) { private sealed record PreferencesPayload( string Theme, string Density, bool PrimaryNavigationCollapsed, bool ContextPanelOpen, string[] NavigationOrder, string[] DashboardWidgetOrder, string[] HiddenDashboardWidgets, string DefaultLandingPage, string CatalogView, int ContextPanelWidth, int NavigationWidth); private static PreferencesPayload DefaultPreferences(string theme = "system") => new( theme, "comfortable", false, false, ["/", "/infrastructure", "/infrastructure/services", "/projects", "/runbooks", "/knowledge", "/activity", "/search"], ["attention", "server", "projects", "activity", "runbooks", "favorites", "quick-actions"], [], "/", "list", 304, 224); private async Task AuthenticatedClientAsync() { var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); return client; } [Fact] public async Task Missing_preferences_return_the_shared_defaults_without_persisting_a_row() { using var client = await AuthenticatedClientAsync(); using var first = await client.GetAsync("/api/v1/me/preferences"); using var firstJson = JsonDocument.Parse(await first.Content.ReadAsStringAsync()); using var second = await client.GetAsync("/api/v1/me/preferences"); using var secondJson = JsonDocument.Parse(await second.Content.ReadAsStringAsync()); Assert.Equal(HttpStatusCode.OK, first.StatusCode); Assert.Equal("\"0\"", first.Headers.ETag?.Tag); Assert.Equal("0", firstJson.RootElement.GetProperty("eTag").GetString()); Assert.True(firstJson.RootElement.GetProperty("isDefault").GetBoolean()); Assert.Equal(1, firstJson.RootElement.GetProperty("schemaVersion").GetInt32()); Assert.Equal("system", firstJson.RootElement.GetProperty("theme").GetString()); Assert.Equal("comfortable", firstJson.RootElement.GetProperty("density").GetString()); Assert.Equal(304, firstJson.RootElement.GetProperty("contextPanelWidth").GetInt32()); Assert.Equal(224, firstJson.RootElement.GetProperty("navigationWidth").GetInt32()); Assert.Equal("0", secondJson.RootElement.GetProperty("eTag").GetString()); Assert.True(secondJson.RootElement.GetProperty("isDefault").GetBoolean()); } [Fact] public async Task Full_replacement_with_the_current_etag_persists_for_the_signed_in_user() { using var client = await AuthenticatedClientAsync(); var request = new PreferencesPayload( "dark", "compact", true, true, ["/projects", "/", "/knowledge"], ["projects", "attention", "server"], ["server"], "/projects", "grid", 420, 248); using var updated = await SetupHelper.PutWithCsrfAsync(client, "/api/v1/me/preferences", request, "0"); using var updatedJson = JsonDocument.Parse(await updated.Content.ReadAsStringAsync()); using var fetched = await client.GetAsync("/api/v1/me/preferences"); using var fetchedJson = JsonDocument.Parse(await fetched.Content.ReadAsStringAsync()); Assert.Equal(HttpStatusCode.OK, updated.StatusCode); Assert.Equal("\"1\"", updated.Headers.ETag?.Tag); Assert.Equal("1", updatedJson.RootElement.GetProperty("eTag").GetString()); Assert.False(updatedJson.RootElement.GetProperty("isDefault").GetBoolean()); Assert.Equal("dark", fetchedJson.RootElement.GetProperty("theme").GetString()); Assert.Equal("/projects", fetchedJson.RootElement.GetProperty("defaultLandingPage").GetString()); Assert.Equal(420, fetchedJson.RootElement.GetProperty("contextPanelWidth").GetInt32()); Assert.Equal("1", fetchedJson.RootElement.GetProperty("eTag").GetString()); } [Fact] public async Task A_missing_if_match_is_rejected_before_updating_preferences() { using var client = await AuthenticatedClientAsync(); var request = DefaultPreferences("light"); using var response = await SetupHelper.PutWithCsrfAsync(client, "/api/v1/me/preferences", request); Assert.Equal((HttpStatusCode)428, response.StatusCode); } [Fact] public async Task A_stale_if_match_is_rejected_without_overwriting_a_newer_preference_version() { using var client = await AuthenticatedClientAsync(); var request = DefaultPreferences("dark"); using var first = await SetupHelper.PutWithCsrfAsync(client, "/api/v1/me/preferences", request, "0"); using var stale = await SetupHelper.PutWithCsrfAsync(client, "/api/v1/me/preferences", request with { Theme = "light" }, "0"); using var fetched = await client.GetAsync("/api/v1/me/preferences"); using var fetchedJson = JsonDocument.Parse(await fetched.Content.ReadAsStringAsync()); Assert.Equal(HttpStatusCode.OK, first.StatusCode); Assert.Equal(HttpStatusCode.PreconditionFailed, stale.StatusCode); Assert.Equal("dark", fetchedJson.RootElement.GetProperty("theme").GetString()); } [Fact] public async Task Preferences_reject_unknown_navigation_and_widget_ids() { using var client = await AuthenticatedClientAsync(); var request = DefaultPreferences() with { NavigationOrder = ["/admin/users/delete"], DashboardWidgetOrder = ["unexpected-widget"], DefaultLandingPage = "/admin/users/delete" }; using var response = await SetupHelper.PutWithCsrfAsync(client, "/api/v1/me/preferences", request, "0"); Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); } [Fact] public async Task Concurrent_first_writes_with_the_default_etag_have_one_winner() { using var firstClient = await AuthenticatedClientAsync(); using var secondClient = await AuthenticatedClientAsync(); var firstWrite = SetupHelper.PutWithCsrfAsync(firstClient, "/api/v1/me/preferences", DefaultPreferences("dark"), "0"); var secondWrite = SetupHelper.PutWithCsrfAsync(secondClient, "/api/v1/me/preferences", DefaultPreferences("light"), "0"); using var firstResponse = await firstWrite; using var secondResponse = await secondWrite; Assert.Equal(1, new[] { firstResponse.StatusCode, secondResponse.StatusCode }.Count(status => status == HttpStatusCode.OK)); Assert.Equal(1, new[] { firstResponse.StatusCode, secondResponse.StatusCode }.Count(status => status == HttpStatusCode.PreconditionFailed)); } }