using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using Azaion.Missions.E2E.Fixtures; using Azaion.Missions.E2E.Helpers; using Xunit; namespace Azaion.Missions.E2E.Tests.Missions; /// /// FT-P-07..11 — mission happy-path scenarios from /// _docs/02_document/tests/blackbox-tests.md § Positive. /// FT-P-12 (cascade delete) lives in because /// it owns its own xUnit collection (the F3 fixture is destructive). /// Traces: AC-2.1 / AC-2.3 / AC-2.4 / AC-2.5 / AC-2.7. /// [Collection("Missions")] [Trait("Category", "Blackbox")] [Trait("db_access", "seed-or-assert-only")] public sealed class PositiveTests : TestBase, IClassFixture { [Fact] [Trait("Traces", "AC-2.1")] [Trait("max_ms", "5000")] public async Task FT_P_07_create_mission_defaults_created_date_to_utc_now() { // Arrange DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel); Seeds.Apply(Seeds.OneDefaultVehicle.Sql); var vehicleId = Seeds.OneDefaultVehicle.Id; var token = await Tokens.MintDefaultAsync(); // Act var t0 = DateTime.UtcNow; using var http = new HttpRequestMessage(HttpMethod.Post, "/missions") { Content = JsonContent.Create(new { Name = "Recon-01", VehicleId = vehicleId, CreatedDate = (DateTime?)null }) }; http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt); using var response = await Missions.SendAsync(http); // Assert await HttpAssertions.AssertStatusAsync(response, HttpStatusCode.Created); var mission = await response.Content.ReadFromJsonAsync() ?? throw new InvalidOperationException("created mission body deserialized to null"); var drift = (mission.CreatedDate.ToUniversalTime() - t0).Duration(); Assert.True(drift <= TimeSpan.FromSeconds(5), $"CreatedDate drift {drift.TotalSeconds:F2}s exceeds 5s tolerance ({mission.CreatedDate:o} vs {t0:o})"); Assert.Equal("Recon-01", mission.Name); Assert.Equal(vehicleId, mission.VehicleId); } [Fact] [Trait("Traces", "AC-2.3,AC-8.7")] [Trait("max_ms", "2000")] [Trait("carry_forward", "json-camelcase-vs-pascalcase")] public async Task FT_P_08_list_returns_paginated_response_in_desc_order_with_case_insensitive_filter() { // Arrange DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel); Seeds.Apply(Seeds.TwentyFiveMissions.Sql); var token = await Tokens.MintDefaultAsync(); // Act using var http = new HttpRequestMessage(HttpMethod.Get, "/missions"); http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt); using var response = await Missions.SendAsync(http); var raw = await response.Content.ReadAsStringAsync(); using var http2 = new HttpRequestMessage(HttpMethod.Get, "/missions?name=re"); http2.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt); using var response2 = await Missions.SendAsync(http2); var page2Raw = await response2.Content.ReadAsStringAsync(); // Assert await HttpAssertions.AssertStatusAsync(response, HttpStatusCode.OK); using var doc = JsonDocument.Parse(raw); var root = doc.RootElement; // CARRY-FORWARD (json-camelcase-vs-pascalcase): results_report.md row 2.3 // pinned PascalCase but the SUT emits camelCase via default ASP.NET // Core JsonSerializerOptions. Test pins the observed shape. Assert.True(root.TryGetProperty("items", out var itemsEl), $"missing 'items': {raw}"); Assert.True(root.TryGetProperty("totalCount", out var totalEl)); Assert.True(root.TryGetProperty("page", out var pageEl)); Assert.True(root.TryGetProperty("pageSize", out var pageSizeEl)); Assert.False(root.TryGetProperty("Items", out _), "envelope unexpectedly PascalCase"); Assert.Equal(1, pageEl.GetInt32()); Assert.Equal(20, pageSizeEl.GetInt32()); Assert.Equal(25, totalEl.GetInt32()); var items = JsonSerializer.Deserialize>(itemsEl.GetRawText()) ?? throw new InvalidOperationException("Items array deserialized to null"); Assert.Equal(20, items.Count); for (var i = 0; i < items.Count - 1; i++) { Assert.True(items[i].CreatedDate >= items[i + 1].CreatedDate, $"DESC ordering broken at index {i}: {items[i].CreatedDate:o} < {items[i + 1].CreatedDate:o}"); } await HttpAssertions.AssertStatusAsync(response2, HttpStatusCode.OK); using var doc2 = JsonDocument.Parse(page2Raw); var totalCaseInsensitive = doc2.RootElement.GetProperty("totalCount").GetInt32(); // The seed alternates names "Recon-NN" and "OPS-NN"; lowercase "re" // must match the "Recon-*" rows (>=12 of them). Assert.True(totalCaseInsensitive > 0, $"case-INSENSITIVE filter ?name=re returned 0; case-sensitive bug suspected ({page2Raw})"); } [Fact] [Trait("Traces", "AC-2.3")] [Trait("max_ms", "2000")] public async Task FT_P_09_page_2_returns_remaining_5_disjoint_from_page_1() { // Arrange DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel); Seeds.Apply(Seeds.TwentyFiveMissions.Sql); var token = await Tokens.MintDefaultAsync(); async Task> FetchAsync(string query) { using var http = new HttpRequestMessage(HttpMethod.Get, "/missions?" + query); http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt); using var resp = await Missions.SendAsync(http); await HttpAssertions.AssertStatusAsync(resp, HttpStatusCode.OK); return await resp.Content.ReadFromJsonAsync>() ?? throw new InvalidOperationException("paginated body deserialized to null"); } // Act var page1 = await FetchAsync("page=1&pageSize=20"); var page2 = await FetchAsync("page=2&pageSize=20"); // Assert Assert.Equal(2, page2.Page); Assert.Equal(20, page2.PageSize); Assert.Equal(25, page2.TotalCount); Assert.Equal(5, page2.Items.Count); var page1Ids = page1.Items.Select(m => m.Id).ToHashSet(); var page2Ids = page2.Items.Select(m => m.Id).ToHashSet(); Assert.False(page1Ids.Overlaps(page2Ids), "page 1 and page 2 share IDs — pagination is broken"); } [Fact] [Trait("Traces", "AC-2.3")] [Trait("max_ms", "2000")] public async Task FT_P_10_date_range_filter_is_inclusive_of_bounds() { // Arrange DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel); Seeds.Apply(Seeds.TwentyFiveMissions.Sql); var token = await Tokens.MintDefaultAsync(); // Act using var http = new HttpRequestMessage( HttpMethod.Get, "/missions?fromDate=2026-01-01T00:00:00Z&toDate=2026-01-31T23:59:59Z&pageSize=100"); http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt); using var response = await Missions.SendAsync(http); // Assert await HttpAssertions.AssertStatusAsync(response, HttpStatusCode.OK); var page = await response.Content.ReadFromJsonAsync>() ?? throw new InvalidOperationException("paginated body deserialized to null"); Assert.Equal(5, page.TotalCount); Assert.All(page.Items, m => { var utc = m.CreatedDate.ToUniversalTime(); Assert.True(utc >= new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), $"mission {m.Id} CreatedDate {utc:o} predates window"); Assert.True(utc <= new DateTime(2026, 1, 31, 23, 59, 59, DateTimeKind.Utc), $"mission {m.Id} CreatedDate {utc:o} postdates window"); }); } [Fact] [Trait("Traces", "AC-2.5")] [Trait("max_ms", "2000")] public async Task FT_P_11_partial_update_preserves_null_vehicle_id() { // Arrange DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel); Seeds.Apply(Seeds.OneDefaultVehicle.Sql); var vehicleId = Seeds.OneDefaultVehicle.Id; var missionId = Guid.NewGuid(); Seeds.Apply($""" INSERT INTO missions (id, created_date, name, vehicle_id) VALUES ('{missionId}', '2026-05-14T00:00:00Z', 'Original', '{vehicleId}'); """); var token = await Tokens.MintDefaultAsync(); // Act using var http = new HttpRequestMessage(HttpMethod.Put, $"/missions/{missionId}") { Content = JsonContent.Create(new { Name = "Renamed", VehicleId = (Guid?)null }) }; http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt); using var response = await Missions.SendAsync(http); // Assert await HttpAssertions.AssertStatusAsync(response, HttpStatusCode.OK); var mission = await response.Content.ReadFromJsonAsync() ?? throw new InvalidOperationException("body deserialized to null"); Assert.Equal("Renamed", mission.Name); Assert.Equal(vehicleId, mission.VehicleId); } }