diff --git a/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminRolesEndpointTests.cs b/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminRolesEndpointTests.cs index 5809a927..7b30e1ca 100644 --- a/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminRolesEndpointTests.cs +++ b/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminRolesEndpointTests.cs @@ -53,6 +53,20 @@ private async Task SeedTestRoleAsync(string? name = null) return role.Id; } + private async Task FindRoleByNameAsync(string name) + { + using var scope = _factory.Services.CreateScope(); + var roleManager = scope.ServiceProvider.GetRequiredService>(); + return await roleManager.FindByNameAsync(name); + } + + private async Task FindRoleByIdAsync(string id) + { + using var scope = _factory.Services.CreateScope(); + var roleManager = scope.ServiceProvider.GetRequiredService>(); + return await roleManager.FindByIdAsync(id); + } + [Fact] public async Task GetRoles_AsAdmin_Returns200() { @@ -86,21 +100,25 @@ public async Task GetRolesCreate_AsAdmin_Returns200() } [Fact] - public async Task CreateRole_ValidData_Redirects() + public async Task CreateRole_ValidData_PersistsRole() { var client = CreateAdminClient(); + var roleName = $"NewRole-{Guid.NewGuid().ToString()[..8]}"; using var content = new FormUrlEncodedContent( new Dictionary { - ["name"] = $"NewRole-{Guid.NewGuid().ToString()[..8]}", + ["name"] = roleName, ["description"] = "A new test role", } ); var response = await client.PostAsync("/admin/roles", content); - response.StatusCode.Should().Be(HttpStatusCode.Redirect); + + var role = await FindRoleByNameAsync(roleName); + role.Should().NotBeNull(); + role!.Description.Should().Be("A new test role"); } [Fact] @@ -140,33 +158,39 @@ public async Task GetRolesEdit_NonExistentRole_Returns404() } [Fact] - public async Task UpdateRole_ValidData_Redirects() + public async Task UpdateRole_ValidData_PersistsChanges() { var roleId = await SeedTestRoleAsync(); + var newName = $"UpdatedRole-{Guid.NewGuid().ToString()[..8]}"; var client = CreateAdminClient(); using var content = new FormUrlEncodedContent( new Dictionary { - ["name"] = $"UpdatedRole-{Guid.NewGuid().ToString()[..8]}", + ["name"] = newName, ["description"] = "Updated description", } ); var response = await client.PostAsync($"/admin/roles/{roleId}", content); - response.StatusCode.Should().Be(HttpStatusCode.Redirect); + + var role = await FindRoleByIdAsync(roleId); + role.Should().NotBeNull(); + role!.Name.Should().Be(newName); + role.Description.Should().Be("Updated description"); } [Fact] - public async Task DeleteRole_NoUsers_Redirects() + public async Task DeleteRole_NoUsers_RemovesRole() { var roleId = await SeedTestRoleAsync(); var client = CreateAdminClient(); var response = await client.DeleteAsync($"/admin/roles/{roleId}"); - response.StatusCode.Should().Be(HttpStatusCode.Redirect); + + (await FindRoleByIdAsync(roleId)).Should().BeNull(); } [Fact] diff --git a/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminUsersEndpointTests.cs b/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminUsersEndpointTests.cs index 96b31068..78aedfaf 100644 --- a/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminUsersEndpointTests.cs +++ b/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminUsersEndpointTests.cs @@ -56,6 +56,15 @@ private async Task SeedTestUserAsync() return userId; } + private async Task FetchUserAsync(string userId) + { + using var scope = _factory.Services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = await userManager.FindByIdAsync(userId); + user.Should().NotBeNull($"user {userId} should exist for assertion"); + return user!; + } + [Fact] public async Task GetUsers_AsAdmin_Returns200() { @@ -88,19 +97,6 @@ public async Task GetUsersCreate_AsAdmin_Returns200() response.StatusCode.Should().Be(HttpStatusCode.OK); } - [Fact( - Skip = "UsersEditEndpoint depends on PermissionRegistry which requires full module initialization in test setup" - )] - public async Task GetUsersEdit_ExistingUser_Returns200() - { - var userId = await SeedTestUserAsync(); - var client = CreateAdminClient(); - - var response = await client.GetAsync($"/admin/users/{userId}/edit"); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - } - [Fact] public async Task GetUsersEdit_NonExistentUser_Returns404() { @@ -112,74 +108,96 @@ public async Task GetUsersEdit_NonExistentUser_Returns404() } [Fact] - public async Task UpdateUser_ValidData_Redirects() + public async Task UpdateUser_ValidData_PersistsDisplayNameAndEmail() { var userId = await SeedTestUserAsync(); + var newEmail = $"updated-{userId[..8]}@example.com"; var client = CreateAdminClient(); using var content = new FormUrlEncodedContent( new Dictionary { ["displayName"] = "Updated Name", - ["email"] = $"updated-{userId[..8]}@example.com", + ["email"] = newEmail, } ); var response = await client.PostAsync($"/admin/users/{userId}", content); - response.StatusCode.Should().Be(HttpStatusCode.Redirect); + + var user = await FetchUserAsync(userId); + user.DisplayName.Should().Be("Updated Name"); + user.Email.Should().Be(newEmail); } [Fact] - public async Task LockUser_ValidUser_Redirects() + public async Task LockUser_SetsLockoutEndInFuture() { var userId = await SeedTestUserAsync(); var client = CreateAdminClient(); var response = await client.PostAsync($"/admin/users/{userId}/lock", null); - response.StatusCode.Should().Be(HttpStatusCode.Redirect); + + var user = await FetchUserAsync(userId); + user.LockoutEnd.Should().NotBeNull(); + user.LockoutEnd!.Value.Should().BeAfter(DateTimeOffset.UtcNow); } [Fact] - public async Task UnlockUser_ValidUser_Redirects() + public async Task UnlockUser_ClearsLockout() { var userId = await SeedTestUserAsync(); var client = CreateAdminClient(); - var response = await client.PostAsync($"/admin/users/{userId}/unlock", null); + await client.PostAsync($"/admin/users/{userId}/lock", null); + (await FetchUserAsync(userId)).LockoutEnd.Should().NotBeNull(); + var response = await client.PostAsync($"/admin/users/{userId}/unlock", null); response.StatusCode.Should().Be(HttpStatusCode.Redirect); + + var user = await FetchUserAsync(userId); + // UnlockAccountAsync sets LockoutEnd to null or to a past timestamp. + (user.LockoutEnd is null || user.LockoutEnd.Value <= DateTimeOffset.UtcNow) + .Should() + .BeTrue(); } [Fact] - public async Task DeactivateUser_ValidUser_Redirects() + public async Task DeactivateUser_SetsDeactivatedAt() { var userId = await SeedTestUserAsync(); var client = CreateAdminClient(); var response = await client.PostAsync($"/admin/users/{userId}/deactivate", null); - response.StatusCode.Should().Be(HttpStatusCode.Redirect); + + var user = await FetchUserAsync(userId); + user.DeactivatedAt.Should().NotBeNull(); + user.DeactivatedAt!.Value.Should() + .BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromMinutes(1)); } [Fact] - public async Task ReactivateUser_ValidUser_Redirects() + public async Task ReactivateUser_ClearsDeactivatedAt() { var userId = await SeedTestUserAsync(); var client = CreateAdminClient(); await client.PostAsync($"/admin/users/{userId}/deactivate", null); + (await FetchUserAsync(userId)).DeactivatedAt.Should().NotBeNull(); var response = await client.PostAsync($"/admin/users/{userId}/reactivate", null); - response.StatusCode.Should().Be(HttpStatusCode.Redirect); + + (await FetchUserAsync(userId)).DeactivatedAt.Should().BeNull(); } [Fact] - public async Task ResetPassword_ValidData_Redirects() + public async Task ResetPassword_ReplacesPasswordHash_NewPasswordAuthenticates() { var userId = await SeedTestUserAsync(); + var oldHash = (await FetchUserAsync(userId)).PasswordHash; var client = CreateAdminClient(); using var content = new FormUrlEncodedContent( @@ -187,8 +205,17 @@ public async Task ResetPassword_ValidData_Redirects() ); var response = await client.PostAsync($"/admin/users/{userId}/reset-password", content); - response.StatusCode.Should().Be(HttpStatusCode.Redirect); + + using var scope = _factory.Services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = await userManager.FindByIdAsync(userId); + user.Should().NotBeNull(); + user!.PasswordHash.Should().NotBe(oldHash, "password reset must replace the hash"); + (await userManager.CheckPasswordAsync(user, "NewTestPass456!")).Should().BeTrue(); + (await userManager.CheckPasswordAsync(user, "TestPass123!")) + .Should() + .BeFalse("the old password must no longer authenticate"); } [Fact] diff --git a/modules/BackgroundJobs/tests/SimpleModule.BackgroundJobs.Tests/Integration/BackgroundJobsEndpointTests.cs b/modules/BackgroundJobs/tests/SimpleModule.BackgroundJobs.Tests/Integration/BackgroundJobsEndpointTests.cs index 9b2aad5a..b4738b1f 100644 --- a/modules/BackgroundJobs/tests/SimpleModule.BackgroundJobs.Tests/Integration/BackgroundJobsEndpointTests.cs +++ b/modules/BackgroundJobs/tests/SimpleModule.BackgroundJobs.Tests/Integration/BackgroundJobsEndpointTests.cs @@ -77,17 +77,6 @@ public async Task GetById_Unauthenticated_Returns401() // --- POST /api/jobs/{id}/cancel --- - [Fact] - public async Task Cancel_WithManagePermission_DoesNotReturn401() - { - var client = _factory.CreateAuthenticatedClient([BackgroundJobsPermissions.ManageJobs]); - - var response = await client.PostAsync($"/api/jobs/{Guid.NewGuid()}/cancel", null); - - // May fail with 404/500 since no real job exists, but should not be 401 - response.StatusCode.Should().NotBe(HttpStatusCode.Unauthorized); - } - [Fact] public async Task Cancel_WithViewOnlyPermission_Returns403() { @@ -101,13 +90,13 @@ public async Task Cancel_WithViewOnlyPermission_Returns403() // --- POST /api/jobs/{id}/retry --- [Fact] - public async Task Retry_WithManagePermission_DoesNotReturn401() + public async Task Retry_WithViewOnlyPermission_Returns403() { - var client = _factory.CreateAuthenticatedClient([BackgroundJobsPermissions.ManageJobs]); + var client = _factory.CreateAuthenticatedClient([BackgroundJobsPermissions.ViewJobs]); var response = await client.PostAsync($"/api/jobs/{Guid.NewGuid()}/retry", null); - response.StatusCode.Should().NotBe(HttpStatusCode.Unauthorized); + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); } [Fact] @@ -167,13 +156,13 @@ public async Task ToggleRecurring_ViewOnly_Returns403() // --- DELETE /api/jobs/recurring/{id} --- [Fact] - public async Task DeleteRecurring_WithManagePermission_DoesNotReturn401() + public async Task DeleteRecurring_WithViewOnlyPermission_Returns403() { - var client = _factory.CreateAuthenticatedClient([BackgroundJobsPermissions.ManageJobs]); + var client = _factory.CreateAuthenticatedClient([BackgroundJobsPermissions.ViewJobs]); var response = await client.DeleteAsync($"/api/jobs/recurring/{Guid.NewGuid()}"); - response.StatusCode.Should().NotBe(HttpStatusCode.Unauthorized); + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); } [Fact] diff --git a/modules/Dashboard/tests/SimpleModule.Dashboard.Tests/Integration/BroadcastingEndpointTests.cs b/modules/Dashboard/tests/SimpleModule.Dashboard.Tests/Integration/BroadcastingEndpointTests.cs new file mode 100644 index 00000000..bf4804c9 --- /dev/null +++ b/modules/Dashboard/tests/SimpleModule.Dashboard.Tests/Integration/BroadcastingEndpointTests.cs @@ -0,0 +1,68 @@ +using System.Net; +using System.Net.Http.Json; +using System.Security.Claims; +using System.Text.Json; +using FluentAssertions; +using SimpleModule.Core.Broadcasting; +using SimpleModule.Core.Inertia; +using SimpleModule.Tests.Shared.Fixtures; + +namespace SimpleModule.Dashboard.Tests.Integration; + +[Collection(TestCollections.Integration)] +public class BroadcastingEndpointTests +{ + private readonly SimpleModuleWebApplicationFactory _factory; + + public BroadcastingEndpointTests(SimpleModuleWebApplicationFactory factory) => + _factory = factory; + + private static void AddInertiaHeaders(HttpClient client) + { + client.DefaultRequestHeaders.Add("X-Inertia", "true"); + client.DefaultRequestHeaders.Add("X-Inertia-Version", InertiaMiddleware.Version); + } + + [Fact] + public async Task Get_Authenticated_Renders_Channel_For_Current_User() + { + const string userId = "user-broadcast-1"; + var client = _factory.CreateAuthenticatedClient( + new Claim(ClaimTypes.NameIdentifier, userId) + ); + AddInertiaHeaders(client); + + var response = await client.GetAsync("/broadcasting"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var json = await response.Content.ReadFromJsonAsync(); + json.GetProperty("component").GetString().Should().Be("Dashboard/Broadcasting"); + + var props = json.GetProperty("props"); + props.GetProperty("userId").GetString().Should().Be(userId); + props.GetProperty("channel").GetString().Should().Be(BroadcastChannels.ForUser(userId)); + props.GetProperty("fireUrl").GetString().Should().Be("/api/dashboard/broadcasting/tick"); + } + + [Fact] + public async Task Tick_Anonymous_Returns_401() + { + var client = _factory.CreateClient(); + + var response = await client.PostAsync("/api/dashboard/broadcasting/tick", null); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Tick_Authenticated_Returns_NoContent() + { + var client = _factory.CreateAuthenticatedClient( + new Claim(ClaimTypes.NameIdentifier, "user-broadcast-2") + ); + + var response = await client.PostAsync("/api/dashboard/broadcasting/tick", null); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } +} diff --git a/modules/Dashboard/tests/SimpleModule.Dashboard.Tests/Integration/HomeEndpointTests.cs b/modules/Dashboard/tests/SimpleModule.Dashboard.Tests/Integration/HomeEndpointTests.cs new file mode 100644 index 00000000..c827e246 --- /dev/null +++ b/modules/Dashboard/tests/SimpleModule.Dashboard.Tests/Integration/HomeEndpointTests.cs @@ -0,0 +1,57 @@ +using System.Net; +using System.Net.Http.Json; +using System.Security.Claims; +using System.Text.Json; +using FluentAssertions; +using SimpleModule.Core.Inertia; +using SimpleModule.Tests.Shared.Fixtures; + +namespace SimpleModule.Dashboard.Tests.Integration; + +[Collection(TestCollections.Integration)] +public class HomeEndpointTests +{ + private readonly SimpleModuleWebApplicationFactory _factory; + + public HomeEndpointTests(SimpleModuleWebApplicationFactory factory) => _factory = factory; + + private static void AddInertiaHeaders(HttpClient client) + { + client.DefaultRequestHeaders.Add("X-Inertia", "true"); + client.DefaultRequestHeaders.Add("X-Inertia-Version", InertiaMiddleware.Version); + } + + [Fact] + public async Task Anonymous_Get_Renders_With_IsAuthenticated_False_And_Default_Name() + { + var client = _factory.CreateClient(); + AddInertiaHeaders(client); + + var response = await client.GetAsync("/"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var json = await response.Content.ReadFromJsonAsync(); + json.GetProperty("component").GetString().Should().Be("Dashboard/Home"); + + var props = json.GetProperty("props"); + props.GetProperty("isAuthenticated").GetBoolean().Should().BeFalse(); + props.GetProperty("displayName").GetString().Should().Be("User"); + props.TryGetProperty("isDevelopment", out _).Should().BeTrue(); + } + + [Fact] + public async Task Authenticated_Get_Renders_With_Claim_Identity_Name() + { + var client = _factory.CreateAuthenticatedClient(new Claim(ClaimTypes.Name, "alice")); + AddInertiaHeaders(client); + + var response = await client.GetAsync("/"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var json = await response.Content.ReadFromJsonAsync(); + + var props = json.GetProperty("props"); + props.GetProperty("isAuthenticated").GetBoolean().Should().BeTrue(); + props.GetProperty("displayName").GetString().Should().Be("alice"); + } +} diff --git a/modules/OpenIddict/tests/SimpleModule.OpenIddict.Tests/Integration/ConnectEndpointTests.cs b/modules/OpenIddict/tests/SimpleModule.OpenIddict.Tests/Integration/ConnectEndpointTests.cs deleted file mode 100644 index d461376b..00000000 --- a/modules/OpenIddict/tests/SimpleModule.OpenIddict.Tests/Integration/ConnectEndpointTests.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System.Net; -using FluentAssertions; -using Microsoft.AspNetCore.Mvc.Testing; -using SimpleModule.Tests.Shared.Fixtures; - -namespace OpenIddict.Tests.Integration; - -[Collection(TestCollections.Integration)] -public class ConnectEndpointTests -{ - private readonly SimpleModuleWebApplicationFactory _factory; - - public ConnectEndpointTests(SimpleModuleWebApplicationFactory factory) => _factory = factory; - - [Fact] - public async Task Authorize_Unauthenticated_ReturnsNonSuccess() - { - var client = _factory.CreateClient( - new WebApplicationFactoryClientOptions { AllowAutoRedirect = false } - ); - - var response = await client.GetAsync( - "/connect/authorize?response_type=code&client_id=simplemodule-client&scope=openid&redirect_uri=https://localhost:5001/oauth-callback" - ); - - // OpenIddict validates the request via its own middleware pipeline; - // without a registered client, this returns 400. With a valid client - // but no authentication, it would redirect to login. - response - .StatusCode.Should() - .BeOneOf( - HttpStatusCode.BadRequest, - HttpStatusCode.Redirect, - HttpStatusCode.Unauthorized - ); - } - - [Fact] - public async Task Authorize_WithoutRequiredParams_ReturnsBadRequest() - { - var client = _factory.CreateClient( - new WebApplicationFactoryClientOptions { AllowAutoRedirect = false } - ); - - // Missing required OpenID Connect parameters - var response = await client.GetAsync("/connect/authorize"); - - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Userinfo_Unauthenticated_ReturnsBadRequestOrUnauthorized() - { - var client = _factory.CreateClient( - new WebApplicationFactoryClientOptions { AllowAutoRedirect = false } - ); - - var response = await client.GetAsync("/connect/userinfo"); - - // OpenIddict rejects the request at the middleware level when no - // valid access token is provided (returns 400 or 401) - response - .StatusCode.Should() - .BeOneOf(HttpStatusCode.BadRequest, HttpStatusCode.Unauthorized); - } - - [Fact] - public async Task EndSession_WithoutToken_ReturnsBadRequestOrSuccess() - { - var client = _factory.CreateClient( - new WebApplicationFactoryClientOptions { AllowAutoRedirect = false } - ); - - var response = await client.GetAsync("/connect/endsession"); - - // OpenIddict validates the end-session request; without a valid - // id_token_hint it may return 400, 200, or redirect - response - .StatusCode.Should() - .BeOneOf(HttpStatusCode.BadRequest, HttpStatusCode.OK, HttpStatusCode.Redirect); - } - - [Fact] - public async Task Token_WithoutCredentials_ReturnsBadRequest() - { - var client = _factory.CreateClient( - new WebApplicationFactoryClientOptions { AllowAutoRedirect = false } - ); - - // Token endpoint requires grant_type and credentials - var response = await client.PostAsync("/connect/token", null); - - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task ConnectEndpoints_AreRegistered() - { - var client = _factory.CreateClient( - new WebApplicationFactoryClientOptions { AllowAutoRedirect = false } - ); - - // Verify endpoints exist (they return 400 from OpenIddict validation, - // not 404 which would indicate they aren't registered) - var authorizeResponse = await client.GetAsync("/connect/authorize"); - var userinfoResponse = await client.GetAsync("/connect/userinfo"); - var endsessionResponse = await client.GetAsync("/connect/endsession"); - - authorizeResponse.StatusCode.Should().NotBe(HttpStatusCode.NotFound); - userinfoResponse.StatusCode.Should().NotBe(HttpStatusCode.NotFound); - endsessionResponse.StatusCode.Should().NotBe(HttpStatusCode.NotFound); - } -} diff --git a/modules/RateLimiting/tests/SimpleModule.RateLimiting.Tests/RateLimitingModuleTests.cs b/modules/RateLimiting/tests/SimpleModule.RateLimiting.Tests/RateLimitingModuleTests.cs deleted file mode 100644 index 33c83164..00000000 --- a/modules/RateLimiting/tests/SimpleModule.RateLimiting.Tests/RateLimitingModuleTests.cs +++ /dev/null @@ -1,68 +0,0 @@ -using FluentAssertions; -using SimpleModule.Core.RateLimiting; - -namespace SimpleModule.RateLimiting.Tests; - -public class RateLimitingModuleTests -{ - [Fact] - public void ConfigureRateLimits_ShouldRegisterBuiltInPolicies() - { - var module = new RateLimitingModule(); - var registry = new RateLimitPolicyRegistry(); - - module.ConfigureRateLimits(registry); - - var policies = registry.GetPolicies(); - policies.Should().HaveCount(4); - policies.Should().Contain(p => p.Name == "fixed-default"); - policies.Should().Contain(p => p.Name == "sliding-strict"); - policies.Should().Contain(p => p.Name == "token-bucket"); - policies.Should().Contain(p => p.Name == "auth-strict"); - } - - [Fact] - public void ConfigureRateLimits_FixedDefault_ShouldHaveCorrectSettings() - { - var module = new RateLimitingModule(); - var registry = new RateLimitPolicyRegistry(); - - module.ConfigureRateLimits(registry); - - var policy = registry.GetPolicy("fixed-default"); - policy.Should().NotBeNull(); - policy!.PolicyType.Should().Be(RateLimitPolicyType.FixedWindow); - policy.Target.Should().Be(RateLimitTarget.Ip); - policy.PermitLimit.Should().Be(60); - policy.Window.Should().Be(TimeSpan.FromMinutes(1)); - } - - [Fact] - public void ConfigureRateLimits_AuthStrict_ShouldHaveLowLimit() - { - var module = new RateLimitingModule(); - var registry = new RateLimitPolicyRegistry(); - - module.ConfigureRateLimits(registry); - - var policy = registry.GetPolicy("auth-strict"); - policy.Should().NotBeNull(); - policy!.PermitLimit.Should().Be(10); - } - - [Fact] - public void ConfigureRateLimits_TokenBucket_ShouldHaveCorrectSettings() - { - var module = new RateLimitingModule(); - var registry = new RateLimitPolicyRegistry(); - - module.ConfigureRateLimits(registry); - - var policy = registry.GetPolicy("token-bucket"); - policy.Should().NotBeNull(); - policy!.PolicyType.Should().Be(RateLimitPolicyType.TokenBucket); - policy.TokenLimit.Should().Be(100); - policy.TokensPerPeriod.Should().Be(10); - policy.ReplenishmentPeriod.Should().Be(TimeSpan.FromSeconds(10)); - } -} diff --git a/modules/Settings/tests/SimpleModule.Settings.Tests/Integration/SettingsEndpointTests.cs b/modules/Settings/tests/SimpleModule.Settings.Tests/Integration/SettingsEndpointTests.cs index 4350f4a5..786a74fc 100644 --- a/modules/Settings/tests/SimpleModule.Settings.Tests/Integration/SettingsEndpointTests.cs +++ b/modules/Settings/tests/SimpleModule.Settings.Tests/Integration/SettingsEndpointTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http.Json; +using System.Text.Json; using FluentAssertions; using SimpleModule.Core.Settings; using SimpleModule.Settings.Contracts; @@ -10,82 +11,112 @@ namespace Settings.Tests.Integration; [Collection(TestCollections.Integration)] public class SettingsEndpointTests(SimpleModuleWebApplicationFactory factory) { - [Fact] - public async Task GetDefinitions_Authenticated_Returns200() - { - var client = factory.CreateAuthenticatedClient(); - var response = await client.GetAsync("/api/settings/definitions"); - response.StatusCode.Should().Be(HttpStatusCode.OK); - } + private static string UniqueKey(string prefix) => $"{prefix}.{Guid.NewGuid():N}"; [Fact] - public async Task UpdateSetting_Authenticated_Returns204() + public async Task UpdateSetting_StoresValue_ReadableViaGetSetting() { var client = factory.CreateAuthenticatedClient(); - var request = new UpdateSettingRequest - { - Key = "test.key", - Value = "\"test-value\"", - Scope = SettingScope.Application, - }; - var response = await client.PutAsJsonAsync("/api/settings", request); - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - } + var key = UniqueKey("integration"); - [Fact] - public async Task GetSetting_AfterUpdate_ReturnsValue() - { - var client = factory.CreateAuthenticatedClient(); - await client.PutAsJsonAsync( + var updateResponse = await client.PutAsJsonAsync( "/api/settings", new UpdateSettingRequest { - Key = "integration.test", + Key = key, Value = "\"hello\"", Scope = SettingScope.Application, } ); - var response = await client.GetAsync("/api/settings/integration.test?scope=1"); - response.StatusCode.Should().Be(HttpStatusCode.OK); + updateResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var getResponse = await client.GetAsync($"/api/settings/{key}?scope=1"); + getResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await getResponse.Content.ReadFromJsonAsync(); + body.GetProperty("key").GetString().Should().Be(key); + // Settings are stored as raw JSON, so the string value comes back + // quoted exactly as it was written. + body.GetProperty("value").GetString().Should().Be("\"hello\""); } [Fact] - public async Task DeleteSetting_Authenticated_Returns204() + public async Task DeleteSetting_RemovesValue_SubsequentGetReturns404() { var client = factory.CreateAuthenticatedClient(); + var key = UniqueKey("delete"); + await client.PutAsJsonAsync( "/api/settings", new UpdateSettingRequest { - Key = "delete.test", + Key = key, Value = "\"temp\"", Scope = SettingScope.Application, } ); - var response = await client.DeleteAsync("/api/settings/delete.test?scope=1"); - response.StatusCode.Should().Be(HttpStatusCode.NoContent); + // Sanity check: the setting exists before deletion. + (await client.GetAsync($"/api/settings/{key}?scope=1")) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + + var deleteResponse = await client.DeleteAsync($"/api/settings/{key}?scope=1"); + deleteResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var afterDelete = await client.GetAsync($"/api/settings/{key}?scope=1"); + afterDelete.StatusCode.Should().Be(HttpStatusCode.NotFound); } [Fact] - public async Task GetMySettings_Authenticated_Returns200() + public async Task GetDefinitions_ReturnsArrayOfDefinitions() { var client = factory.CreateAuthenticatedClient(); - var response = await client.GetAsync("/api/settings/me"); + + var response = await client.GetAsync("/api/settings/definitions"); response.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await response.Content.ReadFromJsonAsync(); + body.ValueKind.Should().Be(JsonValueKind.Array); + // Each item must have at least the canonical "key" property — proves + // the registry is actually populated and serialized, not just that + // the route returned 200. + if (body.GetArrayLength() > 0) + { + body[0].TryGetProperty("key", out _).Should().BeTrue(); + } } [Fact] - public async Task UpdateMySetting_Authenticated_Returns204() + public async Task UpdateMySetting_StoresUserScopedValue_ReadableViaGetMySettings() { var client = factory.CreateAuthenticatedClient(); - var request = new UpdateSettingRequest - { - Key = "app.theme", - Value = "\"dark\"", - Scope = SettingScope.User, - }; - var response = await client.PutAsJsonAsync("/api/settings/me", request); - response.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var theme = "dark-" + Guid.NewGuid().ToString("N")[..8]; + var updateResponse = await client.PutAsJsonAsync( + "/api/settings/me", + new UpdateSettingRequest + { + Key = "app.theme", + Value = $"\"{theme}\"", + Scope = SettingScope.User, + } + ); + updateResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var getResponse = await client.GetAsync("/api/settings/me"); + getResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await getResponse.Content.ReadFromJsonAsync(); + body.ValueKind.Should().Be(JsonValueKind.Array); + // The endpoint returns each user-scope definition with the resolved + // value; the app.theme entry must reflect the override we just set. + var themeEntry = body.EnumerateArray() + .FirstOrDefault(item => + item.TryGetProperty("definition", out var def) + && def.TryGetProperty("key", out var key) + && key.GetString() == "app.theme" + ); + themeEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined); } [Fact] diff --git a/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/TenantHostIdTests.cs b/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/TenantHostIdTests.cs deleted file mode 100644 index 3b162457..00000000 --- a/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/TenantHostIdTests.cs +++ /dev/null @@ -1,24 +0,0 @@ -using FluentAssertions; -using SimpleModule.Tenants.Contracts; - -namespace Tenants.Tests.Unit; - -public class TenantHostIdTests -{ - [Fact] - public void From_CreatesValueObject() - { - var id = TenantHostId.From(42); - - id.Value.Should().Be(42); - } - - [Fact] - public void Equals_WithSameValue_ReturnsTrue() - { - var id1 = TenantHostId.From(1); - var id2 = TenantHostId.From(1); - - id1.Should().Be(id2); - } -} diff --git a/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/TenantIdTests.cs b/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/TenantIdTests.cs deleted file mode 100644 index 0ec3cf19..00000000 --- a/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/TenantIdTests.cs +++ /dev/null @@ -1,33 +0,0 @@ -using FluentAssertions; -using SimpleModule.Tenants.Contracts; - -namespace Tenants.Tests.Unit; - -public class TenantIdTests -{ - [Fact] - public void From_CreatesValueObject() - { - var id = TenantId.From(42); - - id.Value.Should().Be(42); - } - - [Fact] - public void Equals_WithSameValue_ReturnsTrue() - { - var id1 = TenantId.From(1); - var id2 = TenantId.From(1); - - id1.Should().Be(id2); - } - - [Fact] - public void Equals_WithDifferentValue_ReturnsFalse() - { - var id1 = TenantId.From(1); - var id2 = TenantId.From(2); - - id1.Should().NotBe(id2); - } -} diff --git a/modules/Users/tests/SimpleModule.Users.Tests/Integration/UserServiceTests.cs b/modules/Users/tests/SimpleModule.Users.Tests/Integration/UserServiceTests.cs new file mode 100644 index 00000000..5c5bd7d7 --- /dev/null +++ b/modules/Users/tests/SimpleModule.Users.Tests/Integration/UserServiceTests.cs @@ -0,0 +1,296 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using SimpleModule.Core.Exceptions; +using SimpleModule.Tests.Shared.Fakes; +using SimpleModule.Tests.Shared.Fixtures; +using SimpleModule.Users; +using SimpleModule.Users.Contracts; +using SimpleModule.Users.Contracts.Events; + +namespace Users.Tests.Integration; + +[Collection(TestCollections.Integration)] +public sealed class UserServiceTests +{ + private readonly SimpleModuleWebApplicationFactory _factory; + + public UserServiceTests(SimpleModuleWebApplicationFactory factory) + { + _factory = factory; + // Force the host to spin up so the in-memory SQLite schema exists. + _ = _factory.CreateClient(); + } + + private (UserService sut, TestMessageBus bus, IServiceScope scope) CreateSut() + { + var scope = _factory.Services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var roleManager = scope.ServiceProvider.GetRequiredService>(); + var bus = new TestMessageBus(); + var sut = new UserService(userManager, roleManager, bus, NullLogger.Instance); + return (sut, bus, scope); + } + + private static string UniqueEmail() => $"user-{Guid.NewGuid():N}@example.com"; + + [Fact] + public async Task CreateUserAsync_PersistsUser_HashesPassword_AndPublishesEvent() + { + var (sut, bus, scope) = CreateSut(); + using var _ = scope; + + var email = UniqueEmail(); + var dto = await sut.CreateUserAsync( + new CreateUserRequest + { + Email = email, + DisplayName = "Alice", + Password = "TestPass1234!", + } + ); + + dto.Email.Should().Be(email); + dto.DisplayName.Should().Be("Alice"); + dto.Id.Value.Should().NotBeNullOrEmpty(); + + // Verify the user actually persisted, not just returned from the call. + var userManager = scope.ServiceProvider.GetRequiredService>(); + var persisted = await userManager.FindByEmailAsync(email); + persisted.Should().NotBeNull(); + persisted!.DisplayName.Should().Be("Alice"); + + // Identity must hash the password — the stored hash should not equal + // the plaintext, and CheckPasswordAsync should accept the original. + persisted.PasswordHash.Should().NotBeNullOrEmpty().And.NotBe("TestPass1234!"); + (await userManager.CheckPasswordAsync(persisted, "TestPass1234!")).Should().BeTrue(); + + // Event published with the new user's data. + var evt = bus.PublishedEvents.OfType().Single(); + evt.UserId.Should().Be(dto.Id); + evt.Email.Should().Be(email); + evt.DisplayName.Should().Be("Alice"); + } + + [Fact] + public async Task CreateUserAsync_DuplicateEmail_ThrowsValidationException_AndDoesNotPublishEvent() + { + var (sut, bus, scope) = CreateSut(); + using var _ = scope; + var email = UniqueEmail(); + + await sut.CreateUserAsync( + new CreateUserRequest + { + Email = email, + DisplayName = "First", + Password = "TestPass1234!", + } + ); + bus.PublishedEvents.Clear(); + + var act = () => + sut.CreateUserAsync( + new CreateUserRequest + { + Email = email, + DisplayName = "Second", + Password = "TestPass1234!", + } + ); + + await act.Should().ThrowAsync(); + bus.PublishedEvents.OfType().Should().BeEmpty(); + } + + [Fact] + public async Task CreateUserAsync_WeakPassword_ThrowsValidationException() + { + var (sut, _, scope) = CreateSut(); + using var _scope = scope; + + var act = () => + sut.CreateUserAsync( + new CreateUserRequest + { + Email = UniqueEmail(), + DisplayName = "Weak", + Password = "abc", + } + ); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetUserByIdAsync_ReturnsPersistedUser() + { + var (sut, _, scope) = CreateSut(); + using var _scope = scope; + + var created = await sut.CreateUserAsync( + new CreateUserRequest + { + Email = UniqueEmail(), + DisplayName = "Bob", + Password = "TestPass1234!", + } + ); + + var fetched = await sut.GetUserByIdAsync(created.Id); + + fetched.Should().NotBeNull(); + fetched!.Id.Should().Be(created.Id); + fetched.DisplayName.Should().Be("Bob"); + } + + [Fact] + public async Task GetUserByIdAsync_UnknownId_ReturnsNull() + { + var (sut, _, scope) = CreateSut(); + using var _scope = scope; + + var fetched = await sut.GetUserByIdAsync(UserId.From(Guid.NewGuid().ToString())); + + fetched.Should().BeNull(); + } + + [Fact] + public async Task UpdateUserAsync_PersistsChanges_AndPublishesEvent() + { + var (sut, bus, scope) = CreateSut(); + using var _ = scope; + var created = await sut.CreateUserAsync( + new CreateUserRequest + { + Email = UniqueEmail(), + DisplayName = "Original", + Password = "TestPass1234!", + } + ); + bus.PublishedEvents.Clear(); + + var newEmail = UniqueEmail(); + await sut.UpdateUserAsync( + created.Id, + new UpdateUserRequest { Email = newEmail, DisplayName = "Updated" } + ); + + // Re-fetch from the DB to confirm persistence (not just return value). + var userManager = scope.ServiceProvider.GetRequiredService>(); + var persisted = await userManager.FindByIdAsync(created.Id.Value); + persisted.Should().NotBeNull(); + persisted!.Email.Should().Be(newEmail); + persisted.UserName.Should().Be(newEmail); + persisted.DisplayName.Should().Be("Updated"); + + var evt = bus.PublishedEvents.OfType().Single(); + evt.UserId.Should().Be(created.Id); + evt.Email.Should().Be(newEmail); + } + + [Fact] + public async Task UpdateUserAsync_UnknownUser_ThrowsNotFound() + { + var (sut, _, scope) = CreateSut(); + using var _scope = scope; + + var act = () => + sut.UpdateUserAsync( + UserId.From(Guid.NewGuid().ToString()), + new UpdateUserRequest { Email = UniqueEmail(), DisplayName = "x" } + ); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DeleteUserAsync_RemovesFromDatabase_AndPublishesEvent() + { + var (sut, bus, scope) = CreateSut(); + using var _ = scope; + var created = await sut.CreateUserAsync( + new CreateUserRequest + { + Email = UniqueEmail(), + DisplayName = "ToDelete", + Password = "TestPass1234!", + } + ); + bus.PublishedEvents.Clear(); + + await sut.DeleteUserAsync(created.Id); + + var userManager = scope.ServiceProvider.GetRequiredService>(); + (await userManager.FindByIdAsync(created.Id.Value)).Should().BeNull(); + + var evt = bus.PublishedEvents.OfType().Single(); + evt.UserId.Should().Be(created.Id); + } + + [Fact] + public async Task DeleteUserAsync_UnknownUser_ThrowsNotFound() + { + var (sut, _, scope) = CreateSut(); + using var _scope = scope; + + var act = () => sut.DeleteUserAsync(UserId.From(Guid.NewGuid().ToString())); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetRoleIdsByNamesAsync_ReturnsIdsForExistingRoles_AndOmitsUnknown() + { + var (sut, _, scope) = CreateSut(); + using var _scope = scope; + var roleManager = scope.ServiceProvider.GetRequiredService>(); + + var roleA = $"role-a-{Guid.NewGuid():N}"; + var roleB = $"role-b-{Guid.NewGuid():N}"; + await roleManager.CreateAsync(new ApplicationRole { Name = roleA }); + await roleManager.CreateAsync(new ApplicationRole { Name = roleB }); + + var result = await sut.GetRoleIdsByNamesAsync([roleA, roleB, "missing-role"]); + + result.Should().HaveCount(2); + result.Keys.Should().BeEquivalentTo([roleA, roleB]); + + var roleARecord = await roleManager.FindByNameAsync(roleA); + result[roleA].Should().Be(roleARecord!.Id); + } + + [Fact] + public async Task GetAllUsersAsync_ReturnsAllPersistedUsers() + { + var (sut, _, scope) = CreateSut(); + using var _scope = scope; + var marker = $"marker-{Guid.NewGuid():N}"; + + await sut.CreateUserAsync( + new CreateUserRequest + { + Email = $"a-{marker}@example.com", + DisplayName = "A", + Password = "TestPass1234!", + } + ); + await sut.CreateUserAsync( + new CreateUserRequest + { + Email = $"b-{marker}@example.com", + DisplayName = "B", + Password = "TestPass1234!", + } + ); + + var all = await sut.GetAllUsersAsync(); + + all.Where(u => u.Email.Contains(marker, StringComparison.Ordinal)) + .Select(u => u.DisplayName) + .Should() + .BeEquivalentTo(["A", "B"]); + } +} diff --git a/modules/Users/tests/SimpleModule.Users.Tests/SimpleModule.Users.Tests.csproj b/modules/Users/tests/SimpleModule.Users.Tests/SimpleModule.Users.Tests.csproj index 175885e2..a02058d2 100644 --- a/modules/Users/tests/SimpleModule.Users.Tests/SimpleModule.Users.Tests.csproj +++ b/modules/Users/tests/SimpleModule.Users.Tests/SimpleModule.Users.Tests.csproj @@ -12,7 +12,6 @@ - diff --git a/modules/Users/tests/SimpleModule.Users.Tests/Unit/UserIdTests.cs b/modules/Users/tests/SimpleModule.Users.Tests/Unit/UserIdTests.cs index 734fdbcf..6b65e7f7 100644 --- a/modules/Users/tests/SimpleModule.Users.Tests/Unit/UserIdTests.cs +++ b/modules/Users/tests/SimpleModule.Users.Tests/Unit/UserIdTests.cs @@ -6,14 +6,6 @@ namespace Users.Tests.Unit; public sealed class UserIdTests { - [Fact] - public void From_WithValidString_CreatesUserId() - { - var id = UserId.From("user-123"); - - id.Value.Should().Be("user-123"); - } - [Fact] public void From_WithEmptyString_ThrowsException() { @@ -29,21 +21,4 @@ public void From_WithWhitespace_ThrowsException() act.Should().Throw(); } - - [Fact] - public void Equality_SameValue_AreEqual() - { - var id1 = UserId.From("user-abc"); - var id2 = UserId.From("user-abc"); - - id1.Should().Be(id2); - } - - [Fact] - public void ToString_ReturnsValueString() - { - var id = UserId.From("user-456"); - - id.ToString(System.Globalization.CultureInfo.InvariantCulture).Should().Contain("user-456"); - } } diff --git a/modules/Users/tests/SimpleModule.Users.Tests/Unit/UserServiceTests.cs b/modules/Users/tests/SimpleModule.Users.Tests/Unit/UserServiceTests.cs deleted file mode 100644 index 6b970015..00000000 --- a/modules/Users/tests/SimpleModule.Users.Tests/Unit/UserServiceTests.cs +++ /dev/null @@ -1,204 +0,0 @@ -using FluentAssertions; -using Microsoft.AspNetCore.Identity; -using Microsoft.Extensions.Logging.Abstractions; -using NSubstitute; -using SimpleModule.Tests.Shared.Fakes; -using SimpleModule.Users; -using SimpleModule.Users.Contracts; - -namespace Users.Tests.Unit; - -public sealed class UserServiceTests -{ - private readonly UserManager _userManager; - private readonly RoleManager _roleManager; - private readonly UserService _sut; - - public UserServiceTests() - { - _userManager = Substitute.For>( - Substitute.For>(), - null, - null, - null, - null, - null, - null, - null, - null - ); - _roleManager = Substitute.For>( - Substitute.For>(), - null, - null, - null, - null - ); - _sut = new UserService( - _userManager, - _roleManager, - new TestMessageBus(), - NullLogger.Instance - ); - } - - [Fact] - public async Task GetUserByIdAsync_WithExistingUser_ReturnsUserDto() - { - var appUser = new ApplicationUser - { - Id = "1", - Email = "test@test.com", - DisplayName = "Test User", - EmailConfirmed = true, - }; - _userManager.FindByIdAsync("1").Returns(appUser); - - var user = await _sut.GetUserByIdAsync(UserId.From("1")); - - user.Should().NotBeNull(); - user!.Id.Should().Be(UserId.From("1")); - user.DisplayName.Should().Be("Test User"); - user.Email.Should().Be("test@test.com"); - } - - [Fact] - public async Task GetUserByIdAsync_WithNonExistingUser_ReturnsNull() - { - _userManager.FindByIdAsync("999").Returns((ApplicationUser?)null); - - var user = await _sut.GetUserByIdAsync(UserId.From("999")); - - user.Should().BeNull(); - } - - [Fact] - public async Task GetCurrentUserAsync_DelegatesToGetUserByIdAsync() - { - var appUser = new ApplicationUser - { - Id = "1", - Email = "test@test.com", - DisplayName = "Test User", - }; - _userManager.FindByIdAsync("1").Returns(appUser); - - var user = await _sut.GetCurrentUserAsync(UserId.From("1")); - - user.Should().NotBeNull(); - user!.Id.Should().Be(UserId.From("1")); - } - - [Fact] - public async Task CreateUserAsync_WithValidData_ReturnsUserDto() - { - _userManager - .CreateAsync(Arg.Any(), Arg.Any()) - .Returns(callInfo => - { - var u = callInfo.Arg(); - u.Id = "new-id"; - return IdentityResult.Success; - }); - - var request = new CreateUserRequest - { - Email = "new@test.com", - DisplayName = "New User", - Password = "TestPass1234", - }; - - var user = await _sut.CreateUserAsync(request); - - user.Should().NotBeNull(); - user.Email.Should().Be("new@test.com"); - user.DisplayName.Should().Be("New User"); - } - - [Fact] - public async Task CreateUserAsync_WithDuplicateEmail_ThrowsValidationException() - { - _userManager - .CreateAsync(Arg.Any(), Arg.Any()) - .Returns( - IdentityResult.Failed( - new IdentityError - { - Code = "DuplicateEmail", - Description = "Email already taken", - } - ) - ); - - var request = new CreateUserRequest - { - Email = "dup@test.com", - DisplayName = "Dup User", - Password = "TestPass1234", - }; - - var act = () => _sut.CreateUserAsync(request); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task UpdateUserAsync_WithValidData_UpdatesUser() - { - var appUser = new ApplicationUser - { - Id = "1", - Email = "old@test.com", - DisplayName = "Old Name", - }; - _userManager.FindByIdAsync("1").Returns(appUser); - _userManager.UpdateAsync(Arg.Any()).Returns(IdentityResult.Success); - - var request = new UpdateUserRequest { Email = "new@test.com", DisplayName = "New Name" }; - - var user = await _sut.UpdateUserAsync(UserId.From("1"), request); - - user.Should().NotBeNull(); - user.Email.Should().Be("new@test.com"); - user.DisplayName.Should().Be("New Name"); - } - - [Fact] - public async Task UpdateUserAsync_WithNonExistentUser_ThrowsNotFoundException() - { - _userManager.FindByIdAsync("999").Returns((ApplicationUser?)null); - - var request = new UpdateUserRequest { Email = "test@test.com", DisplayName = "Test" }; - - var act = () => _sut.UpdateUserAsync(UserId.From("999"), request); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task DeleteUserAsync_WithExistingUser_DeletesUser() - { - var appUser = new ApplicationUser - { - Id = "1", - Email = "test@test.com", - DisplayName = "Test", - }; - _userManager.FindByIdAsync("1").Returns(appUser); - _userManager.DeleteAsync(Arg.Any()).Returns(IdentityResult.Success); - - await _sut.DeleteUserAsync(UserId.From("1")); - - await _userManager.Received(1).DeleteAsync(appUser); - } - - [Fact] - public async Task DeleteUserAsync_WithNonExistentUser_ThrowsNotFoundException() - { - _userManager.FindByIdAsync("999").Returns((ApplicationUser?)null); - - var act = () => _sut.DeleteUserAsync(UserId.From("999")); - - await act.Should().ThrowAsync(); - } -} diff --git a/tests/SimpleModule.Core.Tests/ExceptionTests.cs b/tests/SimpleModule.Core.Tests/ExceptionTests.cs deleted file mode 100644 index 35adfd66..00000000 --- a/tests/SimpleModule.Core.Tests/ExceptionTests.cs +++ /dev/null @@ -1,127 +0,0 @@ -using FluentAssertions; -using SimpleModule.Core.Exceptions; - -namespace SimpleModule.Core.Tests; - -public class ValidationExceptionTests -{ - [Fact] - public void DefaultConstructor_HasDefaultMessageAndEmptyErrors() - { - var ex = new ValidationException(); - - ex.Message.Should().Be("One or more validation errors occurred."); - ex.Errors.Should().BeEmpty(); - } - - [Fact] - public void MessageConstructor_SetsMessageAndEmptyErrors() - { - var ex = new ValidationException("Custom message"); - - ex.Message.Should().Be("Custom message"); - ex.Errors.Should().BeEmpty(); - } - - [Fact] - public void ErrorsConstructor_SetsDefaultMessageAndErrors() - { - var errors = new Dictionary - { - ["Name"] = ["Name is required"], - ["Email"] = ["Email is invalid", "Email already exists"], - }; - - var ex = new ValidationException(errors); - - ex.Message.Should().Be("One or more validation errors occurred."); - ex.Errors.Should().HaveCount(2); - ex.Errors["Name"].Should().ContainSingle("Name is required"); - ex.Errors["Email"].Should().HaveCount(2); - } - - [Fact] - public void InnerExceptionConstructor_SetsMessageAndInnerException() - { - var inner = new InvalidOperationException("inner"); - var ex = new ValidationException("outer", inner); - - ex.Message.Should().Be("outer"); - ex.InnerException.Should().BeSameAs(inner); - ex.Errors.Should().BeEmpty(); - } -} - -public class NotFoundExceptionTests -{ - [Fact] - public void DefaultConstructor_HasDefaultMessage() - { - var ex = new NotFoundException(); - - ex.Message.Should().Be("The requested resource was not found."); - } - - [Fact] - public void MessageConstructor_SetsMessage() - { - var ex = new NotFoundException("User not found"); - - ex.Message.Should().Be("User not found"); - } - - [Fact] - public void EntityAndIdConstructor_FormatsMessage() - { - var ex = new NotFoundException("Product", 42); - - ex.Message.Should().Be("Product with ID 42 not found"); - } - - [Fact] - public void EntityAndStringIdConstructor_FormatsMessage() - { - var ex = new NotFoundException("User", "abc-123"); - - ex.Message.Should().Be("User with ID abc-123 not found"); - } - - [Fact] - public void InnerExceptionConstructor_SetsMessageAndInnerException() - { - var inner = new InvalidOperationException("db error"); - var ex = new NotFoundException("not found", inner); - - ex.Message.Should().Be("not found"); - ex.InnerException.Should().BeSameAs(inner); - } -} - -public class ConflictExceptionTests -{ - [Fact] - public void DefaultConstructor_HasDefaultMessage() - { - var ex = new ConflictException(); - - ex.Message.Should().Be("A conflict occurred."); - } - - [Fact] - public void MessageConstructor_SetsMessage() - { - var ex = new ConflictException("Duplicate entry"); - - ex.Message.Should().Be("Duplicate entry"); - } - - [Fact] - public void InnerExceptionConstructor_SetsMessageAndInnerException() - { - var inner = new InvalidOperationException("db constraint"); - var ex = new ConflictException("conflict", inner); - - ex.Message.Should().Be("conflict"); - ex.InnerException.Should().BeSameAs(inner); - } -} diff --git a/tests/SimpleModule.Core.Tests/IModuleTests.cs b/tests/SimpleModule.Core.Tests/IModuleTests.cs index 04f7b175..ee4d4835 100644 --- a/tests/SimpleModule.Core.Tests/IModuleTests.cs +++ b/tests/SimpleModule.Core.Tests/IModuleTests.cs @@ -9,8 +9,6 @@ namespace SimpleModule.Core.Tests; public class IModuleTests { - private sealed class EmptyModule : IModule { } - private sealed class TestModule : IModule { public void ConfigureServices(IServiceCollection services, IConfiguration configuration) @@ -28,17 +26,6 @@ private sealed class TestService { } private static IConfiguration CreateEmptyConfiguration() => new ConfigurationBuilder().Build(); - [Fact] - public void DefaultMethods_DoNotThrow() - { - IModule module = new EmptyModule(); - var services = new ServiceCollection(); - - var act = () => module.ConfigureServices(services, CreateEmptyConfiguration()); - - act.Should().NotThrow(); - } - [Fact] public void ConcreteModule_ConfigureServices_RegistersExpectedServices() { diff --git a/tests/SimpleModule.DevTools.Tests/FakeHostEnvironment.cs b/tests/SimpleModule.DevTools.Tests/FakeHostEnvironment.cs new file mode 100644 index 00000000..62de95d1 --- /dev/null +++ b/tests/SimpleModule.DevTools.Tests/FakeHostEnvironment.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; + +namespace SimpleModule.DevTools.Tests; + +internal sealed class FakeHostEnvironment(string contentRootPath) : IHostEnvironment +{ + public string ApplicationName { get; set; } = "TestApp"; + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); + public string ContentRootPath { get; set; } = contentRootPath; + public string EnvironmentName { get; set; } = "Development"; +} diff --git a/tests/SimpleModule.DevTools.Tests/FileWatcherIntegrationTests.cs b/tests/SimpleModule.DevTools.Tests/FileWatcherIntegrationTests.cs deleted file mode 100644 index 0244af5a..00000000 --- a/tests/SimpleModule.DevTools.Tests/FileWatcherIntegrationTests.cs +++ /dev/null @@ -1,119 +0,0 @@ -using FluentAssertions; -using Microsoft.Extensions.FileProviders; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging.Abstractions; - -namespace SimpleModule.DevTools.Tests; - -public sealed class FileWatcherIntegrationTests : IDisposable -{ - private readonly string _tempDir = Path.Combine( - Path.GetTempPath(), - $"devtools-test-{Guid.NewGuid():N}" - ); - - private readonly LiveReloadServer _liveReload = new(NullLogger.Instance); - - public FileWatcherIntegrationTests() - { - Directory.CreateDirectory(_tempDir); - } - - public void Dispose() - { - _liveReload.Dispose(); - if (Directory.Exists(_tempDir)) - { - Directory.Delete(_tempDir, recursive: true); - } - } - - [Fact] - public async Task Watches_ClientApp_When_Directory_Exists() - { - Directory.CreateDirectory(Path.Combine(_tempDir, ".git")); - - var hostDir = Path.Combine(_tempDir, "template", "SimpleModule.Host"); - var clientAppDir = Path.Combine(hostDir, "ClientApp"); - Directory.CreateDirectory(clientAppDir); - - var env = new FakeHostEnvironment(hostDir); - using var service = new ViteDevWatchService( - NullLogger.Instance, - env, - _liveReload - ); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - await service.StartAsync(cts.Token); - await Task.Delay(100); - - // Watcher is active — writing a file should not throw - await File.WriteAllTextAsync(Path.Combine(clientAppDir, "test.ts"), "export {}"); - await Task.Delay(100); - - await service.StopAsync(CancellationToken.None); - } - - [Fact] - public async Task Watches_Styles_When_Directory_Exists() - { - Directory.CreateDirectory(Path.Combine(_tempDir, ".git")); - - var hostDir = Path.Combine(_tempDir, "template", "SimpleModule.Host"); - var stylesDir = Path.Combine(hostDir, "Styles"); - Directory.CreateDirectory(stylesDir); - - var env = new FakeHostEnvironment(hostDir); - using var service = new ViteDevWatchService( - NullLogger.Instance, - env, - _liveReload - ); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - await service.StartAsync(cts.Token); - await Task.Delay(100); - - // Watcher is active — writing a file should not throw - await File.WriteAllTextAsync(Path.Combine(stylesDir, "test.css"), "body {}"); - await Task.Delay(100); - - await service.StopAsync(CancellationToken.None); - } - - [Fact] - public async Task Skips_ClientApp_When_Directory_Missing() - { - Directory.CreateDirectory(Path.Combine(_tempDir, ".git")); - - var hostDir = Path.Combine(_tempDir, "template", "SimpleModule.Host"); - Directory.CreateDirectory(hostDir); - // Intentionally do NOT create ClientApp/ - - var env = new FakeHostEnvironment(hostDir); - using var service = new ViteDevWatchService( - NullLogger.Instance, - env, - _liveReload - ); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - await service.StartAsync(cts.Token); - await Task.Delay(100); - await service.StopAsync(CancellationToken.None); - - // No exception = correctly skipped missing directory - } -} - -/// -/// Minimal IHostEnvironment implementation for testing. -/// -internal sealed class FakeHostEnvironment(string contentRootPath) : IHostEnvironment -{ - public string ApplicationName { get; set; } = "TestApp"; - public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); - public string ContentRootPath { get; set; } = contentRootPath; - public string EnvironmentName { get; set; } = "Development"; -}