mirror of
https://github.com/azaion/missions.git
synced 2026-06-22 23:11:06 +00:00
[AZ-577] [AZ-578] [AZ-579] [AZ-580] Implement E2E test batch 2
Adds 26 blackbox tests (FT-P-01..18, FT-N-01..08) covering full AC
matrices for Vehicles/Missions/Waypoints/Health/Errors. Three
spec-vs-code carry-forwards documented in batch_02_report.md and
pinned with [Trait("carry_forward", ...)].
Shared scaffolding: ApiDtos.cs, AssertProblemEnvelopeAsync helper,
Seeds.cs, StubSchema.cs, CascadeF3/F4 fixtures, PostgresStopStart
fixture (gated by COMPOSE_RESTART_ENABLED). Removes the 4 placeholder
Sanity.cs files (now superseded). docker-compose.test.yml gains the
expected_results volume mount + FIXTURE_SQL_DIR for the consumer.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// FT-P-07..11 — mission happy-path scenarios from
|
||||
/// <c>_docs/02_document/tests/blackbox-tests.md § Positive</c>.
|
||||
/// FT-P-12 (cascade delete) lives in <see cref="CascadeF3Tests"/> 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.
|
||||
/// </summary>
|
||||
[Collection("Missions")]
|
||||
[Trait("Category", "Blackbox")]
|
||||
[Trait("db_access", "seed-or-assert-only")]
|
||||
public sealed class PositiveTests : TestBase, IClassFixture<DbResetFixture>
|
||||
{
|
||||
[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<MissionDto>() ?? 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")]
|
||||
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;
|
||||
// Pin PascalCase paginated-response envelope (results_report.md row 2.3).
|
||||
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 camelCase");
|
||||
|
||||
Assert.Equal(1, pageEl.GetInt32());
|
||||
Assert.Equal(20, pageSizeEl.GetInt32());
|
||||
Assert.Equal(25, totalEl.GetInt32());
|
||||
|
||||
var items = JsonSerializer.Deserialize<List<MissionDto>>(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<PaginatedResponseDto<MissionDto>> 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<PaginatedResponseDto<MissionDto>>()
|
||||
?? 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<PaginatedResponseDto<MissionDto>>()
|
||||
?? 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<MissionDto>() ?? throw new InvalidOperationException("body deserialized to null");
|
||||
Assert.Equal("Renamed", mission.Name);
|
||||
Assert.Equal(vehicleId, mission.VehicleId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user