using System.Net; using System.Net.Http.Json; using System.Text.Json; using Azaion.E2E.Helpers; using FluentAssertions; using Xunit; namespace Azaion.E2E.Tests; [Collection("E2E")] public sealed class ResourceUpdateTests { private static readonly JsonSerializerOptions ResponseJsonOptions = new() { PropertyNameCaseInsensitive = true }; private sealed record ResourceUpdateItemDto( string ResourceName, string Version, string CdnUrl, string Sha256, string EncryptionKey, long SizeBytes); private readonly TestFixture _fixture; public ResourceUpdateTests(TestFixture fixture) => _fixture = fixture; private static object PublishBody(string resourceName, string version, string arch = "arm64", string stage = "stage", string encryptionKey = "test-resource-key-001") => new { resourceName, devStage = stage, architecture = arch, version, cdnUrl = $"https://cdn.example.com/{resourceName}-{version}.bin", sha256 = "abc123def456789", encryptionKey, sizeBytes = 1024L }; private async Task NewUploaderTokenAsync() { using var loginClient = _fixture.CreateApiClient(); return await loginClient.LoginAsync(_fixture.UploaderEmail, _fixture.UploaderPassword); } [Fact] public async Task AC2_GetUpdate_returns_resources_newer_than_device_version() { // Arrange var uploaderToken = await NewUploaderTokenAsync(); using var uploaderClient = _fixture.CreateAuthenticatedClient(uploaderToken); using var deviceClient = _fixture.CreateAuthenticatedClient(_fixture.AdminToken); var arch = "arm64"; var stage = $"stage-{Guid.NewGuid():N}".Substring(0, 12); var resourceName = $"annotations-{Guid.NewGuid():N}".Substring(0, 20); using var publish = await uploaderClient.PostAsync("/resources/publish", PublishBody(resourceName, "2026-04-13", arch, stage, "device-key-AC2")); publish.StatusCode.Should().Be(HttpStatusCode.OK); // Act using var response = await deviceClient.PostAsync("/get-update", new { architecture = arch, devStage = stage, currentVersions = new Dictionary { [resourceName] = "2026-02-25" } }); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); var items = await response.Content.ReadFromJsonAsync>(ResponseJsonOptions); items.Should().NotBeNull(); items!.Should().HaveCount(1); items![0].ResourceName.Should().Be(resourceName); items[0].Version.Should().Be("2026-04-13"); items[0].CdnUrl.Should().Be($"https://cdn.example.com/{resourceName}-2026-04-13.bin"); items[0].Sha256.Should().Be("abc123def456789"); items[0].EncryptionKey.Should().Be("device-key-AC2", "the column is AES-encrypted at rest but the response must contain plaintext for the device"); items[0].SizeBytes.Should().Be(1024L); } [Fact] public async Task AC3_GetUpdate_returns_empty_when_device_already_has_latest() { // Arrange var uploaderToken = await NewUploaderTokenAsync(); using var uploaderClient = _fixture.CreateAuthenticatedClient(uploaderToken); using var deviceClient = _fixture.CreateAuthenticatedClient(_fixture.AdminToken); var arch = "arm64"; var stage = $"stage-{Guid.NewGuid():N}".Substring(0, 12); var resourceName = $"weights-{Guid.NewGuid():N}".Substring(0, 20); using var publish = await uploaderClient.PostAsync("/resources/publish", PublishBody(resourceName, "2026-04-13", arch, stage)); publish.StatusCode.Should().Be(HttpStatusCode.OK); // Act using var response = await deviceClient.PostAsync("/get-update", new { architecture = arch, devStage = stage, currentVersions = new Dictionary { [resourceName] = "2026-04-13" } }); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); var items = await response.Content.ReadFromJsonAsync>(ResponseJsonOptions); items.Should().NotBeNull(); items!.Should().BeEmpty(); } [Fact] public async Task AC5_Cache_is_invalidated_on_publish() { // Arrange var uploaderToken = await NewUploaderTokenAsync(); using var uploaderClient = _fixture.CreateAuthenticatedClient(uploaderToken); using var deviceClient = _fixture.CreateAuthenticatedClient(_fixture.AdminToken); var arch = "arm64"; var stage = $"stage-{Guid.NewGuid():N}".Substring(0, 12); var resourceName = $"models-{Guid.NewGuid():N}".Substring(0, 20); using var publishV1 = await uploaderClient.PostAsync("/resources/publish", PublishBody(resourceName, "2026-02-25", arch, stage)); publishV1.StatusCode.Should().Be(HttpStatusCode.OK); var deviceVersionsAtV1 = new { architecture = arch, devStage = stage, currentVersions = new Dictionary { [resourceName] = "2026-02-25" } }; using (var primeCache = await deviceClient.PostAsync("/get-update", deviceVersionsAtV1)) { primeCache.StatusCode.Should().Be(HttpStatusCode.OK); var primed = await primeCache.Content.ReadFromJsonAsync>(ResponseJsonOptions); primed!.Should().BeEmpty(); } // Act using var publishV2 = await uploaderClient.PostAsync("/resources/publish", PublishBody(resourceName, "2026-04-13", arch, stage)); publishV2.StatusCode.Should().Be(HttpStatusCode.OK); using var afterPublish = await deviceClient.PostAsync("/get-update", deviceVersionsAtV1); // Assert afterPublish.StatusCode.Should().Be(HttpStatusCode.OK); var items = await afterPublish.Content.ReadFromJsonAsync>(ResponseJsonOptions); items.Should().NotBeNull(); items!.Should().HaveCount(1, "publish must invalidate the per-(arch,stage) latest-versions cache"); items![0].Version.Should().Be("2026-04-13"); } [Fact] public async Task GetUpdate_without_jwt_returns_401() { // Arrange using var client = _fixture.CreateApiClient(); // Act using var response = await client.PostAsync("/get-update", new { architecture = "arm64", devStage = "stage", currentVersions = new Dictionary() }); // Assert response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } }