mirror of
https://github.com/azaion/missions.git
synced 2026-06-22 18:21:08 +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,34 @@
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
|
||||
namespace Azaion.Missions.E2E.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Loads <c>fixture_cascade_F3.sql</c> into a freshly-reset DB. The fixture
|
||||
/// builds a full mission cascade chain (1 mission → 2 waypoints → 2 media →
|
||||
/// 2 annotations → 2 detection rows + 3 map_objects) so a single
|
||||
/// <c>DELETE /missions/{id}</c> exercises every dependency table.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The borrowed-schema tables (media, annotations, detection) must exist
|
||||
/// before the SQL runs — see <see cref="StubSchema"/>. The fixture is
|
||||
/// deliberately destructive (TRUNCATE … CASCADE in the reset step) so it
|
||||
/// must NOT share state with read-path scenarios; tests using it should
|
||||
/// live in their own xUnit collection.
|
||||
/// </remarks>
|
||||
public sealed class CascadeF3Fixture : IDisposable
|
||||
{
|
||||
public static readonly Guid VehicleId =
|
||||
Guid.Parse("11111111-0000-0000-0000-000000000001");
|
||||
|
||||
public static readonly Guid MissionId =
|
||||
Guid.Parse("22222222-0000-0000-0000-000000000001");
|
||||
|
||||
public CascadeF3Fixture()
|
||||
{
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
StubSchema.EnsureCreated();
|
||||
Seeds.Apply(FixtureSql.Load("fixture_cascade_F3"));
|
||||
}
|
||||
|
||||
public void Dispose() { /* Next fixture's reset cleans up. */ }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Azaion.Missions.E2E.Helpers;
|
||||
|
||||
namespace Azaion.Missions.E2E.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Loads <c>fixture_cascade_F4.sql</c> — the scoped waypoint cascade fixture.
|
||||
/// One mission with TWO waypoints, each carrying its own media/annotation/detection
|
||||
/// chain. FT-P-18 deletes the target waypoint and asserts the SIBLING
|
||||
/// waypoint's chain remains intact.
|
||||
/// </summary>
|
||||
public sealed class CascadeF4Fixture : IDisposable
|
||||
{
|
||||
public static readonly Guid VehicleId =
|
||||
Guid.Parse("11111111-0000-0000-0000-000000000004");
|
||||
|
||||
public static readonly Guid MissionId =
|
||||
Guid.Parse("22222222-0000-0000-0000-000000000004");
|
||||
|
||||
public static readonly Guid TargetWaypointId =
|
||||
Guid.Parse("33333333-0000-0000-0000-00000000F4A1");
|
||||
|
||||
public static readonly Guid SiblingWaypointId =
|
||||
Guid.Parse("33333333-0000-0000-0000-00000000F4B2");
|
||||
|
||||
public const string TargetMediaId = "media-F4-target-001";
|
||||
public const string SiblingMediaId = "media-F4-sibling-002";
|
||||
public const string TargetAnnotationId = "anno-F4-target-001";
|
||||
public const string SiblingAnnotationId = "anno-F4-sibling-002";
|
||||
|
||||
public CascadeF4Fixture()
|
||||
{
|
||||
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
|
||||
StubSchema.EnsureCreated();
|
||||
Seeds.Apply(FixtureSql.Load("fixture_cascade_F4"));
|
||||
}
|
||||
|
||||
public void Dispose() { /* Next fixture's reset cleans up. */ }
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Azaion.Missions.E2E.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Stop/start helper for the postgres-test compose service. Used by FT-P-17
|
||||
/// to prove that <c>/health</c> does not ping the database — the fixture
|
||||
/// stops postgres-test, the test asserts /health still returns 200, and the
|
||||
/// fixture restarts postgres-test in teardown.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Like <see cref="ComposeRestartFixture"/>, this fixture only runs when
|
||||
/// <c>COMPOSE_RESTART_ENABLED=1</c>. The e2e-consumer image needs the
|
||||
/// docker CLI on PATH and a docker socket bind to actually drive compose.
|
||||
/// Tests using the fixture must skip with a clear reason when disabled.
|
||||
/// </remarks>
|
||||
public sealed class PostgresStopStartFixture
|
||||
{
|
||||
public bool Enabled => Environment.GetEnvironmentVariable("COMPOSE_RESTART_ENABLED") == "1";
|
||||
|
||||
public string ComposeFile =>
|
||||
Environment.GetEnvironmentVariable("COMPOSE_FILE_PATH") ?? "/workspace/docker-compose.test.yml";
|
||||
|
||||
public string ServiceName =>
|
||||
Environment.GetEnvironmentVariable("POSTGRES_SERVICE_NAME") ?? "postgres-test";
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
EnsureEnabled();
|
||||
Run("docker", $"compose -f {ComposeFile} stop {ServiceName}");
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
EnsureEnabled();
|
||||
Run("docker", $"compose -f {ComposeFile} start {ServiceName}");
|
||||
// Wait for the service to report healthy via pg_isready before
|
||||
// returning — otherwise the next test would hit ConnectionRefused.
|
||||
WaitUntilHealthy();
|
||||
}
|
||||
|
||||
private void WaitUntilHealthy()
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(30);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
try
|
||||
{
|
||||
Run("docker",
|
||||
$"compose -f {ComposeFile} exec -T {ServiceName} pg_isready -U postgres -d azaion");
|
||||
return;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
Thread.Sleep(500);
|
||||
}
|
||||
}
|
||||
throw new InvalidOperationException(
|
||||
$"postgres service '{ServiceName}' did not become ready within 30s after start");
|
||||
}
|
||||
|
||||
private void EnsureEnabled()
|
||||
{
|
||||
if (!Enabled)
|
||||
throw new InvalidOperationException(
|
||||
"PostgresStopStartFixture is disabled; set COMPOSE_RESTART_ENABLED=1 to use it.");
|
||||
}
|
||||
|
||||
private static void Run(string file, string args)
|
||||
{
|
||||
var psi = new ProcessStartInfo(file, args)
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
using var p = Process.Start(psi)
|
||||
?? throw new InvalidOperationException($"Failed to launch {file} {args}");
|
||||
p.WaitForExit();
|
||||
if (p.ExitCode != 0)
|
||||
{
|
||||
var err = p.StandardError.ReadToEnd();
|
||||
throw new InvalidOperationException($"`{file} {args}` exited {p.ExitCode}: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using Npgsql;
|
||||
|
||||
namespace Azaion.Missions.E2E.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Inline seed-data definitions referenced by name from
|
||||
/// <c>_docs/02_document/tests/test-data.md § Seed Data Sets</c>. Each seed
|
||||
/// is idempotent against a freshly-reset DB (callers must run
|
||||
/// <see cref="DbResetFixture.ResetDatabase(string)"/> first; the
|
||||
/// <see cref="DbSeedFixture{TSeed}"/> base does this automatically).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// UUIDs are deterministic so assertions can reference them directly without
|
||||
/// having to first read them back. Seeds insert rows that satisfy every
|
||||
/// schema constraint — including the partial unique index
|
||||
/// <c>ux_vehicles_one_default</c> (a fixture cannot stage two
|
||||
/// is_default=true rows even though the test name suggests it).
|
||||
/// </remarks>
|
||||
public static class Seeds
|
||||
{
|
||||
/// <summary>seed_one_default_vehicle: a single Bayraktar with is_default=true.</summary>
|
||||
public static class OneDefaultVehicle
|
||||
{
|
||||
public static readonly Guid Id =
|
||||
Guid.Parse("11111111-1111-1111-1111-000000000001");
|
||||
|
||||
public const string Sql = """
|
||||
INSERT INTO vehicles
|
||||
(id, type, model, name, fuel_type, battery_capacity,
|
||||
engine_consumption, engine_consumption_idle, is_default)
|
||||
VALUES
|
||||
('11111111-1111-1111-1111-000000000001',
|
||||
0, 'Bayraktar', 'BR-default', 1, 0, 5, 1, true);
|
||||
""";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// seed_3_vehicles_2_default — name-misleading: only ONE row is default
|
||||
/// because the partial unique index <c>ux_vehicles_one_default</c> rejects
|
||||
/// two. The "2" in the name historically referred to a pre-B12 variant
|
||||
/// allowing two defaults; today only BR-01 carries the flag. This still
|
||||
/// satisfies every consumer scenario (FT-P-04 ordering, FT-P-05 filter,
|
||||
/// FT-N-01 no-match) — none of them require >1 default.
|
||||
///
|
||||
/// Insert order is reverse-alphabetic ([MQ-9, BR-02, BR-01]) so an
|
||||
/// ordering bug in the SUT (missing OrderBy) would surface immediately
|
||||
/// — see Risk #2 in _docs/tasks/done/AZ-577_test_vehicles_positive.md.
|
||||
/// </summary>
|
||||
public static class Three_BR01_BR02_MQ9
|
||||
{
|
||||
public static readonly Guid IdBr01 =
|
||||
Guid.Parse("11111111-2222-3333-4444-000000000001");
|
||||
public static readonly Guid IdBr02 =
|
||||
Guid.Parse("11111111-2222-3333-4444-000000000002");
|
||||
public static readonly Guid IdMq9 =
|
||||
Guid.Parse("11111111-2222-3333-4444-000000000003");
|
||||
|
||||
public const string Sql = """
|
||||
INSERT INTO vehicles
|
||||
(id, type, model, name, fuel_type, battery_capacity,
|
||||
engine_consumption, engine_consumption_idle, is_default)
|
||||
VALUES
|
||||
('11111111-2222-3333-4444-000000000003',
|
||||
0, 'Bayraktar', 'MQ-9', 1, 0, 5, 1, false),
|
||||
('11111111-2222-3333-4444-000000000002',
|
||||
0, 'Bayraktar', 'BR-02', 1, 0, 5, 1, false),
|
||||
('11111111-2222-3333-4444-000000000001',
|
||||
0, 'Bayraktar', 'BR-01', 1, 0, 5, 1, true);
|
||||
""";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// seed_25_missions: 5 in January 2026, 20 in February 2026; CreatedDate
|
||||
/// values are spaced ≥ 1 second apart so DESC ordering is deterministic
|
||||
/// (FT-P-08 risk #2). Names alternate between "Recon-N" and "OPS-N" so
|
||||
/// the case-INSENSITIVE name=re filter returns >0 rows.
|
||||
/// </summary>
|
||||
public static class TwentyFiveMissions
|
||||
{
|
||||
public static readonly Guid VehicleId =
|
||||
Guid.Parse("11111111-aaaa-aaaa-aaaa-000000000001");
|
||||
|
||||
// The 5 January CreatedDate values are 2026-01-15T10:00:[00..04]Z so
|
||||
// every mission has a distinct, deterministic CreatedDate.
|
||||
public static string Sql
|
||||
{
|
||||
get
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("""
|
||||
INSERT INTO vehicles
|
||||
(id, type, model, name, fuel_type, battery_capacity,
|
||||
engine_consumption, engine_consumption_idle, is_default)
|
||||
VALUES
|
||||
('11111111-aaaa-aaaa-aaaa-000000000001',
|
||||
0, 'Bayraktar', 'BR-fixture-25', 1, 0, 5, 1, false);
|
||||
""");
|
||||
sb.AppendLine("INSERT INTO missions (id, created_date, name, vehicle_id) VALUES");
|
||||
for (var i = 0; i < 25; i++)
|
||||
{
|
||||
var month = i < 5 ? "01" : "02";
|
||||
var day = i < 5 ? (15 + i).ToString("D2") : (1 + (i - 5)).ToString("D2");
|
||||
var second = (i % 60).ToString("D2");
|
||||
var minute = ((i / 60) % 60).ToString("D2");
|
||||
var name = (i % 2 == 0) ? $"Recon-{i:D2}" : $"OPS-{i:D2}";
|
||||
var idHex = (i + 1).ToString("D12");
|
||||
sb.Append("('22222222-bbbb-bbbb-bbbb-").Append(idHex).Append("', ");
|
||||
sb.Append("'2026-").Append(month).Append('-').Append(day);
|
||||
sb.Append('T').Append("10:").Append(minute).Append(':').Append(second).Append("Z', ");
|
||||
sb.Append('\'').Append(name).Append("', ");
|
||||
sb.Append("'11111111-aaaa-aaaa-aaaa-000000000001')");
|
||||
sb.AppendLine(i == 24 ? ";" : ",");
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// seed_5_waypoints_unordered: 5 waypoints under one mission with
|
||||
/// OrderNum values [3, 1, 2, 5, 4] inserted in that order. The shuffled
|
||||
/// insert order forces FT-P-13 to fail loudly if the SUT forgets the
|
||||
/// OrderBy(w => w.OrderNum) clause.
|
||||
/// </summary>
|
||||
public static class FiveWaypointsUnordered
|
||||
{
|
||||
public static readonly Guid VehicleId =
|
||||
Guid.Parse("11111111-cccc-cccc-cccc-000000000001");
|
||||
public static readonly Guid MissionId =
|
||||
Guid.Parse("22222222-cccc-cccc-cccc-000000000001");
|
||||
|
||||
public const string Sql = """
|
||||
INSERT INTO vehicles
|
||||
(id, type, model, name, fuel_type, battery_capacity,
|
||||
engine_consumption, engine_consumption_idle, is_default)
|
||||
VALUES
|
||||
('11111111-cccc-cccc-cccc-000000000001',
|
||||
0, 'Bayraktar', 'BR-wp-fixture', 1, 0, 5, 1, false);
|
||||
|
||||
INSERT INTO missions (id, created_date, name, vehicle_id)
|
||||
VALUES
|
||||
('22222222-cccc-cccc-cccc-000000000001',
|
||||
'2026-05-14T00:00:00Z', 'wp-fixture', '11111111-cccc-cccc-cccc-000000000001');
|
||||
|
||||
INSERT INTO waypoints
|
||||
(id, mission_id, lat, lon, mgrs, waypoint_source,
|
||||
waypoint_objective, order_num, height)
|
||||
VALUES
|
||||
('33333333-cccc-cccc-cccc-000000000001',
|
||||
'22222222-cccc-cccc-cccc-000000000001', 50.45, 30.52, NULL, 0, 0, 3, 100),
|
||||
('33333333-cccc-cccc-cccc-000000000002',
|
||||
'22222222-cccc-cccc-cccc-000000000001', 50.46, 30.53, NULL, 0, 0, 1, 110),
|
||||
('33333333-cccc-cccc-cccc-000000000003',
|
||||
'22222222-cccc-cccc-cccc-000000000001', 50.47, 30.54, NULL, 0, 0, 2, 120),
|
||||
('33333333-cccc-cccc-cccc-000000000004',
|
||||
'22222222-cccc-cccc-cccc-000000000001', 50.48, 30.55, NULL, 0, 0, 5, 130),
|
||||
('33333333-cccc-cccc-cccc-000000000005',
|
||||
'22222222-cccc-cccc-cccc-000000000001', 50.49, 30.56, NULL, 0, 0, 4, 140);
|
||||
""";
|
||||
}
|
||||
|
||||
public static void Apply(string sql)
|
||||
{
|
||||
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Npgsql;
|
||||
|
||||
namespace Azaion.Missions.E2E.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Creates the borrowed-schema stub tables (media, annotations, detection)
|
||||
/// required by the cascade-delete fixtures. The migrator (<c>DatabaseMigrator</c>)
|
||||
/// only owns the missions/vehicles/waypoints/map_objects tables; media,
|
||||
/// annotations, and detection are owned by sibling services in production
|
||||
/// (out of scope for this repo per
|
||||
/// _docs/02_document/tests/environment.md). The cascade walk in
|
||||
/// <c>MissionService.DeleteMission</c> still references them, so tests must
|
||||
/// supply their schema via side-channel.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Idempotent — every statement is <c>CREATE … IF NOT EXISTS</c>.
|
||||
/// Column shapes match the LinqToDB entities (<c>Database/Entities/Media.cs</c>,
|
||||
/// <c>Database/Entities/Annotation.cs</c>, <c>Database/Entities/Detection.cs</c>).
|
||||
/// </remarks>
|
||||
public static class StubSchema
|
||||
{
|
||||
public static void EnsureCreated()
|
||||
{
|
||||
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
CREATE TABLE IF NOT EXISTS media (
|
||||
id TEXT PRIMARY KEY,
|
||||
waypoint_id UUID
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS annotations (
|
||||
id TEXT PRIMARY KEY,
|
||||
media_id TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS detection (
|
||||
id UUID PRIMARY KEY,
|
||||
annotation_id TEXT NOT NULL
|
||||
);
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user