Files
Oleksandr Bezdieniezhnykh 3398ec49a0
ci/woodpecker/push/build-arm Pipeline was successful
Enhance test infrastructure and configuration for JWKS and Docker setup
- Updated Azaion.Missions.csproj to exclude test sources from service compilation, preventing build failures due to test project dependencies.
- Modified docker-compose.test.yml to preload the pg_stat_statements extension for testing and adjusted JWT refresh intervals for better test execution timing.
- Enhanced Dockerfile to install wget for health checks and ensure proper initialization of the container.
- Introduced a test-only endpoint for JWKS refresh to facilitate end-to-end testing without relying on the default refresh intervals.
- Updated DTOs in ApiDtos.cs to reflect camelCase naming conventions for consistency with service responses.
- Improved test cases to handle JWKS rotation and refresh scenarios effectively, ensuring robust validation of JWT handling.

This commit lays the groundwork for more reliable and efficient testing of the Azaion.Missions project.
2026-05-16 10:20:38 +03:00

216 lines
9.4 KiB
C#

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")]
[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<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);
}
}