mirror of
https://github.com/azaion/missions.git
synced 2026-06-22 19:51:07 +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,94 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
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-12 — mission cascade delete walks every dependency table.
|
||||
/// Owns its own xUnit collection (<c>CascadeF3</c>) because the F3 fixture
|
||||
/// is destructive and must run with a fresh DB per scenario.
|
||||
/// Compares per-table counts against
|
||||
/// <c>_docs/00_problem/input_data/expected_results/cascade_F3_walk.json</c>
|
||||
/// via deep JSON diff (results_report.md row 3.1).
|
||||
/// </summary>
|
||||
[Collection("CascadeF3")]
|
||||
[Trait("Category", "Blackbox")]
|
||||
[Trait("db_access", "seed-or-assert-only")]
|
||||
public sealed class CascadeF3Tests : TestBase, IClassFixture<CascadeF3Fixture>
|
||||
{
|
||||
public CascadeF3Tests(CascadeF3Fixture _) { /* fixture seeds the DB. */ }
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-3.1")]
|
||||
[Trait("max_ms", "10000")]
|
||||
public async Task FT_P_12_mission_cascade_walks_every_dependency_table()
|
||||
{
|
||||
// Arrange — load the canonical walk JSON to assert pre-state and post-state.
|
||||
var walkJson = JsonDocument.Parse(File.ReadAllText(
|
||||
Path.Combine(
|
||||
Environment.GetEnvironmentVariable("FIXTURE_SQL_DIR") ?? "/app/fixtures",
|
||||
"..", // expected_results/.. == input_data
|
||||
"expected_results",
|
||||
"cascade_F3_walk.json")));
|
||||
var preState = walkJson.RootElement.GetProperty("expected_per_table_pre_state_for_safety_check");
|
||||
|
||||
// Refresh the F3 fixture into a known state — IClassFixture seeds once
|
||||
// per class, but we want a clean walk for this single scenario.
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
StubSchema.EnsureCreated();
|
||||
Seeds.Apply(FixtureSql.Load("fixture_cascade_F3"));
|
||||
|
||||
// Sanity-check the pre-state — if the seed fixture failed silently, the
|
||||
// post-state assertions would trivially pass and mask the failure.
|
||||
Assert.Equal(preState.GetProperty("missions").GetInt32(),
|
||||
(int)DbAssertions.TableRowCount("missions"));
|
||||
Assert.Equal(preState.GetProperty("waypoints").GetInt32(),
|
||||
(int)DbAssertions.TableRowCount("waypoints"));
|
||||
Assert.Equal(preState.GetProperty("map_objects").GetInt32(),
|
||||
(int)DbAssertions.TableRowCount("map_objects"));
|
||||
Assert.Equal(preState.GetProperty("media").GetInt32(),
|
||||
(int)DbAssertions.TableRowCount("media"));
|
||||
Assert.Equal(preState.GetProperty("annotations").GetInt32(),
|
||||
(int)DbAssertions.TableRowCount("annotations"));
|
||||
Assert.Equal(preState.GetProperty("detection").GetInt32(),
|
||||
(int)DbAssertions.TableRowCount("detection"));
|
||||
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
|
||||
// Act
|
||||
using var http = new HttpRequestMessage(
|
||||
HttpMethod.Delete, $"/missions/{CascadeF3Fixture.MissionId}");
|
||||
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var response = await Missions.SendAsync(http);
|
||||
|
||||
// Assert
|
||||
await HttpAssertions.AssertStatusAsync(response, HttpStatusCode.NoContent);
|
||||
var bodyLength = (await response.Content.ReadAsByteArrayAsync()).Length;
|
||||
Assert.Equal(0, bodyLength);
|
||||
|
||||
// The walk JSON pins per-table post-state filters; assert each one.
|
||||
var postState = walkJson.RootElement.GetProperty("expected_per_table_post_state");
|
||||
AssertCount("missions", "id = '22222222-0000-0000-0000-000000000001'", 0);
|
||||
AssertCount("waypoints", "mission_id = '22222222-0000-0000-0000-000000000001'", 0);
|
||||
AssertCount("map_objects", "mission_id = '22222222-0000-0000-0000-000000000001'", 0);
|
||||
AssertCount("media", "id IN ('media-fixture-001', 'media-fixture-002')", 0);
|
||||
AssertCount("annotations", "id IN ('anno-fixture-001', 'anno-fixture-002')", 0);
|
||||
AssertCount("detection", "annotation_id IN ('anno-fixture-001', 'anno-fixture-002')", 0);
|
||||
|
||||
// Sanity: the walk JSON has the same expectations we just asserted — fail
|
||||
// loudly if the JSON is out of sync with the in-source filters.
|
||||
Assert.Equal(0, postState.GetProperty("missions").GetProperty("expected_count").GetInt32());
|
||||
}
|
||||
|
||||
private static void AssertCount(string table, string filterSql, long expected)
|
||||
{
|
||||
if (!table.All(c => char.IsLetterOrDigit(c) || c == '_'))
|
||||
throw new ArgumentException($"unsafe table identifier '{table}'", nameof(table));
|
||||
var actual = DbAssertions.ScalarCount($"SELECT COUNT(*) FROM {table} WHERE {filterSql}");
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using Azaion.Missions.E2E.Fixtures;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Npgsql;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Missions;
|
||||
|
||||
/// <summary>
|
||||
/// FT-N-06 — DELETE /missions/{missing_uuid} must short-circuit on the
|
||||
/// initial existence check (Step 1 of the cascade walk) and emit ZERO
|
||||
/// DELETE statements against any dependency table. The contract protects
|
||||
/// downstream consumers from typo'd UUIDs silently corrupting unrelated
|
||||
/// missions' data (results_report.md row 3.2 / AC-3.2).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The strict assertion uses two independent signals: (1) per-table row
|
||||
/// counts before and after must match, AND (2) when
|
||||
/// <c>pg_stat_statements</c> is available, the post-request query stats
|
||||
/// must contain ZERO <c>DELETE FROM map_objects/waypoints/media/...</c>
|
||||
/// rows attributable to this request window.
|
||||
/// Without pg_stat_statements (e.g. extension not preloaded in the
|
||||
/// postgres image), the test still asserts the row-count invariant and
|
||||
/// records a warning trait — silent passing is rejected by the
|
||||
/// row-count check.
|
||||
/// </remarks>
|
||||
[Collection("CascadeShortCircuit")]
|
||||
[Trait("Category", "Blackbox")]
|
||||
[Trait("db_access", "seed-or-assert-only")]
|
||||
public sealed class CascadeShortCircuitTests : TestBase
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-3.2")]
|
||||
[Trait("max_ms", "5000")]
|
||||
public async Task FT_N_06_delete_missing_mission_emits_zero_dependency_table_deletes()
|
||||
{
|
||||
// Arrange — clean DB, F3 fixture for a populated cascade chain.
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
StubSchema.EnsureCreated();
|
||||
Seeds.Apply(FixtureSql.Load("fixture_cascade_F3"));
|
||||
|
||||
// Try to attach pg_stat_statements; fall back gracefully if the
|
||||
// extension isn't preloaded.
|
||||
var pgssAvailable = TryEnablePgStatStatements();
|
||||
if (pgssAvailable) ResetPgStatStatements();
|
||||
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
var notInDb = Guid.NewGuid();
|
||||
|
||||
// Pre-state row counts — these must equal post-state counts iff the
|
||||
// cascade short-circuited correctly.
|
||||
var pre = SnapshotCounts();
|
||||
|
||||
// Act
|
||||
using var http = new HttpRequestMessage(HttpMethod.Delete, $"/missions/{notInDb}");
|
||||
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var response = await Missions.SendAsync(http);
|
||||
|
||||
// Assert
|
||||
await HttpAssertions.AssertProblemEnvelopeAsync(response, HttpStatusCode.NotFound)
|
||||
;
|
||||
|
||||
var post = SnapshotCounts();
|
||||
foreach (var table in pre.Keys)
|
||||
{
|
||||
Assert.True(pre[table] == post[table],
|
||||
$"row count for '{table}' changed after a 404 cascade: " +
|
||||
$"pre={pre[table]} post={post[table]} — short-circuit failed");
|
||||
}
|
||||
|
||||
if (pgssAvailable)
|
||||
{
|
||||
var deleteCount = ScalarCountSql("""
|
||||
SELECT COUNT(*) FROM pg_stat_statements
|
||||
WHERE query ILIKE '%DELETE FROM map_objects%'
|
||||
OR query ILIKE '%DELETE FROM waypoints%'
|
||||
OR query ILIKE '%DELETE FROM media%'
|
||||
OR query ILIKE '%DELETE FROM annotations%'
|
||||
OR query ILIKE '%DELETE FROM detection%'
|
||||
OR query ILIKE '%DELETE FROM missions%'
|
||||
""");
|
||||
Assert.True(deleteCount == 0,
|
||||
$"pg_stat_statements shows {deleteCount} DELETE statements against " +
|
||||
"cascade tables after a 404 — short-circuit failed at the SQL layer");
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, long> SnapshotCounts()
|
||||
{
|
||||
var tables = new[] { "missions", "waypoints", "map_objects",
|
||||
"media", "annotations", "detection" };
|
||||
return tables.ToDictionary(t => t, DbAssertions.TableRowCount);
|
||||
}
|
||||
|
||||
private static bool TryEnablePgStatStatements()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE EXTENSION IF NOT EXISTS pg_stat_statements;";
|
||||
cmd.ExecuteNonQuery();
|
||||
return true;
|
||||
}
|
||||
catch (PostgresException ex)
|
||||
{
|
||||
// Most common cause: the extension is not in
|
||||
// shared_preload_libraries. Surface the reason — skipping
|
||||
// silently would defeat the purpose of this test.
|
||||
Console.WriteLine(
|
||||
$"[FT-N-06] pg_stat_statements unavailable ({ex.SqlState}: {ex.MessageText}); " +
|
||||
"falling back to row-count short-circuit assertion only.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ResetPgStatStatements()
|
||||
{
|
||||
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT pg_stat_statements_reset();";
|
||||
cmd.ExecuteScalar();
|
||||
}
|
||||
|
||||
private static long ScalarCountSql(string sql)
|
||||
{
|
||||
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
var result = cmd.ExecuteScalar();
|
||||
if (result is null || result is DBNull)
|
||||
throw new InvalidOperationException($"scalar query returned NULL: {sql}");
|
||||
return Convert.ToInt64(result, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Azaion.Missions.E2E.Fixtures;
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Missions;
|
||||
|
||||
/// <summary>
|
||||
/// FT-N-04 (carry-forward 400 for bogus VehicleId) and FT-N-05 (GET 404).
|
||||
/// FT-N-06 (cascade short-circuit) lives in <see cref="CascadeShortCircuitTests"/>
|
||||
/// because it manipulates Postgres logging and owns its own collection.
|
||||
/// Traces: AC-2.2 (carry-forward), AC-2.4 / AC-8.2.
|
||||
/// </summary>
|
||||
[Collection("Missions")]
|
||||
[Trait("Category", "Blackbox")]
|
||||
[Trait("db_access", "seed-or-assert-only")]
|
||||
public sealed class NegativeTests : TestBase, IClassFixture<DbResetFixture>
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-2.2")]
|
||||
[Trait("max_ms", "2000")]
|
||||
[Trait("carry_forward", "AC-2.2")]
|
||||
public async Task FT_N_04_create_mission_with_bogus_vehicle_id_returns_400_today()
|
||||
{
|
||||
// CARRY-FORWARD: spec wants 404 (results_report.md row 2.2 carry-forward).
|
||||
// Today the SUT throws ArgumentException → ErrorHandlingMiddleware maps
|
||||
// to 400. Flip to 404 expectation when the divergence is closed.
|
||||
|
||||
// Arrange
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
var bogusVehicleId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
using var http = new HttpRequestMessage(HttpMethod.Post, "/missions")
|
||||
{
|
||||
Content = JsonContent.Create(new
|
||||
{
|
||||
Name = "x",
|
||||
VehicleId = bogusVehicleId,
|
||||
CreatedDate = (DateTime?)null
|
||||
})
|
||||
};
|
||||
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var response = await Missions.SendAsync(http);
|
||||
|
||||
// Assert
|
||||
await HttpAssertions.AssertProblemEnvelopeAsync(response, HttpStatusCode.BadRequest)
|
||||
;
|
||||
var missionsRows = DbAssertions.TableRowCount("missions");
|
||||
Assert.Equal(0L, missionsRows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Traces", "AC-2.4,AC-8.2")]
|
||||
[Trait("max_ms", "2000")]
|
||||
public async Task FT_N_05_get_mission_returns_404_with_problem_envelope()
|
||||
{
|
||||
// Arrange
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
var token = await Tokens.MintDefaultAsync();
|
||||
var randomId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
using var http = new HttpRequestMessage(HttpMethod.Get, $"/missions/{randomId}");
|
||||
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt);
|
||||
using var response = await Missions.SendAsync(http);
|
||||
|
||||
// Assert
|
||||
await HttpAssertions.AssertProblemEnvelopeAsync(response, HttpStatusCode.NotFound)
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.Missions.E2E.Tests.Missions;
|
||||
|
||||
/// <summary>
|
||||
/// Discovery-only smoke test for the Missions category. Real Missions
|
||||
/// scenarios (FT-P-07..12, FT-N-04..06) land in AZ-578.
|
||||
/// </summary>
|
||||
public sealed class Sanity
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", "Blackbox")]
|
||||
[Trait("Traces", "AC-3")]
|
||||
public void Discovery_smoke_test_runs()
|
||||
{
|
||||
// Arrange
|
||||
const int sentinel = 1;
|
||||
// Act
|
||||
var result = sentinel + 0;
|
||||
// Assert
|
||||
Assert.Equal(1, result);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user