using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using Campaign_Tracker.Server.Audit; using Campaign_Tracker.Server.ElectionCycles; using Microsoft.Extensions.DependencyInjection; namespace Campaign_Tracker.Server.Tests; public sealed partial class ElectionCycleJobControllerTests { [Fact] public async Task CreateJob_ValidRequestWithExistingCycle_Returns201AndInSetupStatus_AC1_AC3() { await using var factory = new AuthIntegrationTestFactory(); using var client = CreateClient(factory); var response = await client.PostAsJsonAsync( "/api/election-cycles/jobs", new { jCode = "FAIR01", cycleId = "2026-primary", cycleName = "2026 Primary" }); Assert.Equal(HttpStatusCode.Created, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); Assert.Equal("FAIR01", body.JCode); Assert.Equal("2026-primary", body.CycleId); Assert.Equal("2026 Primary", body.CycleName); Assert.Equal("In Setup", body.Status); Assert.Equal("cs@example.test", body.CreatedBy); } [Fact] public async Task CreateJob_NewCycleName_GeneratesCycleIdAndCreatesJob_AC1() { await using var factory = new AuthIntegrationTestFactory(); using var client = CreateClient(factory); var response = await client.PostAsJsonAsync( "/api/election-cycles/jobs", new { jCode = "PINE03", cycleName = "2027 General" }); Assert.Equal(HttpStatusCode.Created, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); Assert.Equal("PINE03", body.JCode); Assert.Equal("2027-general", body.CycleId); Assert.Equal("2027 General", body.CycleName); Assert.Equal("In Setup", body.Status); } [Fact] public async Task CreateJob_MissingCycleSelection_Returns422WithValidationMessage_AC4() { await using var factory = new AuthIntegrationTestFactory(); using var client = CreateClient(factory); var response = await client.PostAsJsonAsync( "/api/election-cycles/jobs", new { jCode = "FAIR01" }); Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); var problem = await response.Content.ReadFromJsonAsync(); Assert.NotNull(problem); Assert.Contains("required", problem.Error, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task CreateJob_MissingJCode_ReturnsClientError_AC4() { await using var factory = new AuthIntegrationTestFactory(); using var client = CreateClient(factory); var response = await client.PostAsJsonAsync( "/api/election-cycles/jobs", new { cycleId = "2026-primary", cycleName = "2026 Primary" }); // Either 400 (model binding) or 422 (our validation) — both reject the request. Assert.True(response.StatusCode == HttpStatusCode.BadRequest || response.StatusCode == HttpStatusCode.UnprocessableEntity); } [Fact] public async Task CreateJob_RejectsUnauthenticatedUser_AC5() { await using var factory = new AuthIntegrationTestFactory(); using var noToken = factory.CreateClient(); var response = await noToken.PostAsJsonAsync( "/api/election-cycles/jobs", new { jCode = "FAIR01", cycleId = "2026-primary", cycleName = "2026 Primary" }); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] public async Task CreateJob_RejectsNonClientServicesRole_AC5() { 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/election-cycles/jobs", new { jCode = "FAIR01", cycleId = "2026-primary", cycleName = "2026 Primary" }); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } [Fact] public async Task CreateJob_EmitsAuditEvent_AC5() { await using var factory = new AuthIntegrationTestFactory(); using var client = CreateClient(factory); var response = await client.PostAsJsonAsync( "/api/election-cycles/jobs", new { jCode = "LAKE02", cycleId = "2026-primary", cycleName = "2026 Primary" }); Assert.Equal(HttpStatusCode.Created, response.StatusCode); var auditService = factory.Services.GetRequiredService(); var events = auditService.GetRecent(); Assert.Contains(events, e => e.EventType == "ELECTION_CYCLE_JOB_CREATED" && e.ActorIdentity == "cs@example.test" && e.Outcome.Contains("LAKE02")); } [Fact] public async Task CreateJob_DoesNotWriteToLegacyTables_AC5() { // The repository is IElectionCycleJobRepository — an extension-layer write path. // ILegacyDataAccess has only Get* methods (read-only). Verify the interface contract. var legacyMethods = typeof(Campaign_Tracker.Server.LegacyData.ILegacyDataAccess).GetMethods(); Assert.True( legacyMethods.All(m => m.Name.StartsWith("Get", StringComparison.Ordinal)), "ILegacyDataAccess must only expose read methods — no writes to legacy tables."); } [Fact] public async Task CreateJob_IdempotentForSameJCodeAndCycle() { await using var factory = new AuthIntegrationTestFactory(); using var client = CreateClient(factory); var response1 = await client.PostAsJsonAsync( "/api/election-cycles/jobs", new { jCode = "FAIR01", cycleId = "2026-primary", cycleName = "2026 Primary" }); var response2 = await client.PostAsJsonAsync( "/api/election-cycles/jobs", new { jCode = "FAIR01", cycleId = "2026-primary", cycleName = "2026 Primary" }); Assert.Equal(HttpStatusCode.Created, response1.StatusCode); Assert.Equal(HttpStatusCode.Created, response2.StatusCode); var body1 = await response1.Content.ReadFromJsonAsync(); var body2 = await response2.Content.ReadFromJsonAsync(); Assert.NotNull(body1); Assert.NotNull(body2); Assert.Equal(body1.JobId, body2.JobId); } [Fact] public async Task GetById_UnknownJob_Returns404() { await using var factory = new AuthIntegrationTestFactory(); using var client = CreateClient(factory); var response = await client.GetAsync("/api/election-cycles/jobs/does-not-exist"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } private static HttpClient CreateClient(AuthIntegrationTestFactory factory) { var client = factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", AuthIntegrationTestFactory.CreateToken("cs@example.test", "client-services")); return client; } private sealed record ElectionCycleJobDto( string JobId, string JCode, string CycleId, string CycleName, string Status, string CreatedBy, string CreatedAt); private sealed record ElectionCycleJobProblemDto(string Error); }