using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using Campaign_Tracker.Server.Audit; using Microsoft.Extensions.DependencyInjection; namespace Campaign_Tracker.Server.Tests; public sealed class MunicipalityProfileControllerTests { // ── AC #1: profile created and saved with legacy link ──────────────────── [Fact] public async Task CreateProfile_ValidJCode_Returns200WithCombinedView_AC1_AC2() { await using var factory = new AuthIntegrationTestFactory(); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", AuthIntegrationTestFactory.CreateToken("cs@example.test", "client-services")); var response = await client.PostAsJsonAsync("/api/municipalities/profiles", new { jCode = "FAIR01", displayName = "Fairview Borough Profile", }); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); Assert.Equal("FAIR01", body.JCode); Assert.Equal("Fairview Borough Profile", body.DisplayName); // AC #2: combined view includes resolved legacy name Assert.Equal("Fairview Borough", body.LegacyName); } // ── AC #2: list returns combined extension + legacy fields ──────────────── [Fact] public async Task GetAllProfiles_ReturnsResolvedLegacyData_AC2() { await using var factory = new AuthIntegrationTestFactory(); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", AuthIntegrationTestFactory.CreateToken("cs@example.test", "client-services")); await client.PostAsJsonAsync("/api/municipalities/profiles", new { jCode = "LAKE02", displayName = (string?)null }); var response = await client.GetAsync("/api/municipalities/profiles"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var profiles = await response.Content.ReadFromJsonAsync(); Assert.NotNull(profiles); var lake = Assert.Single(profiles, p => p.JCode == "LAKE02"); Assert.Equal("Lake Township", lake.LegacyName); } // ── AC #3: update audits the change (server-side; response includes actor) ─ [Fact] public async Task UpdateProfile_ChangesDisplayName_Returns200_AC3() { await using var factory = new AuthIntegrationTestFactory(); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", AuthIntegrationTestFactory.CreateToken("cs@example.test", "client-services")); var created = await (await client.PostAsJsonAsync("/api/municipalities/profiles", new { jCode = "FAIR01", displayName = "Old Name" })) .Content.ReadFromJsonAsync(); var response = await client.PutAsJsonAsync( $"/api/municipalities/profiles/{created!.ProfileId}", new { displayName = "New Name" }); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var updated = await response.Content.ReadFromJsonAsync(); Assert.Equal("New Name", updated!.DisplayName); } // ── AC #4: invalid JCode rejected before save ───────────────────────────── [Fact] public async Task CreateProfile_InvalidJCode_Returns422WithDescription_AC4() { await using var factory = new AuthIntegrationTestFactory(); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", AuthIntegrationTestFactory.CreateToken("cs@example.test", "client-services")); var response = await client.PostAsJsonAsync("/api/municipalities/profiles", new { jCode = "DOESNOTEXIST", displayName = (string?)null, }); Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); Assert.Contains("DOESNOTEXIST", body.Error); } // ── Authorization ───────────────────────────────────────────────────────── [Fact] public async Task CreateProfile_NoToken_Returns401() { await using var factory = new AuthIntegrationTestFactory(); using var client = factory.CreateClient(); var response = await client.PostAsJsonAsync("/api/municipalities/profiles", new { jCode = "FAIR01", displayName = (string?)null }); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] public async Task CreateProfile_WrongRoleToken_Returns403() { await using var factory = new AuthIntegrationTestFactory(); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", AuthIntegrationTestFactory.CreateToken("prod@example.test", "production")); var response = await client.PostAsJsonAsync("/api/municipalities/profiles", new { jCode = "FAIR01", displayName = (string?)null }); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } [Fact] public async Task GetJurisdictions_NoToken_Returns401() { await using var factory = new AuthIntegrationTestFactory(); using var client = factory.CreateClient(); var response = await client.GetAsync("/api/municipalities/jurisdictions"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } // ── AC #3: audit events recorded on create and update ──────────────────── [Fact] public async Task CreateProfile_RecordsAuditEvent_AC3() { await using var factory = new AuthIntegrationTestFactory(); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", AuthIntegrationTestFactory.CreateToken("cs@example.test", "client-services")); await client.PostAsJsonAsync("/api/municipalities/profiles", new { jCode = "FAIR01", displayName = "Fairview" }); var auditService = factory.Services.GetRequiredService(); var events = auditService.GetRecent(); Assert.Contains(events, e => e.EventType == "MUNICIPALITY_PROFILE_CREATED" && e.ActorIdentity == "cs@example.test"); } [Fact] public async Task UpdateProfile_RecordsAuditEvent_AC3() { await using var factory = new AuthIntegrationTestFactory(); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", AuthIntegrationTestFactory.CreateToken("cs@example.test", "client-services")); var created = await (await client.PostAsJsonAsync("/api/municipalities/profiles", new { jCode = "LAKE02", displayName = (string?)null })) .Content.ReadFromJsonAsync(); await client.PutAsJsonAsync( $"/api/municipalities/profiles/{created!.ProfileId}", new { displayName = "Updated Name" }); var auditService = factory.Services.GetRequiredService(); var events = auditService.GetRecent(); Assert.Contains(events, e => e.EventType == "MUNICIPALITY_PROFILE_UPDATED" && e.ActorIdentity == "cs@example.test"); } // ── Update not-found returns 404 ───────────────────────────────────────── [Fact] public async Task UpdateProfile_UnknownId_Returns404() { await using var factory = new AuthIntegrationTestFactory(); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", AuthIntegrationTestFactory.CreateToken("cs@example.test", "client-services")); var response = await client.PutAsJsonAsync( "/api/municipalities/profiles/does-not-exist", new { displayName = "X" }); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } // ── Local DTOs for deserialization ──────────────────────────────────────── private sealed record MunicipalityProfileDto( string ProfileId, string JCode, string? DisplayName, string UpdatedAt, string UpdatedBy, string? LegacyName, string? LegacyMailingAddress, string? LegacyCityStateZip); private sealed record MunicipalityProfileProblemDto(string Error); }