mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 22:51:10 +00:00
[AZ-513] [AZ-196] [AZ-183] Add /classes CRUD, /devices, fleet OTA
AZ-513: POST/PATCH/DELETE /classes for detection-class CRUD; new DetectionClass entity, schema, DTOs, IDetectionClassService. Unblocks ui/AZ-512. AZ-196: POST /devices auto-assigns sequential azj-NNNN serial+email +password and inserts a CompanionPC user. Returns plaintext credentials for the provisioning script. AZ-183: Resources table + POST /get-update + POST /resources/publish for fleet OTA. Per-resource encryption_key column AES-256-CBC encrypted at rest with ResourcesConfig.EncryptionMasterKey; ICache wraps the per-(arch,stage) latest-versions lookup and is invalidated on publish. Adds IDbFactory.RunAdmin<T> overload for write-and-return. Backfills _docs/02_document/module-layout.md to satisfy the implement skill's File Ownership prerequisite (the _docs/ artifact set predates the Step 1.5 module-layout addition). Code review: PASS_WITH_WARNINGS — see _docs/03_implementation/reviews/batch_05_review.md. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -64,6 +64,13 @@ public sealed class ApiClient : IDisposable
|
||||
return _httpClient.PutAsync(url, content, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<HttpResponseMessage> PatchAsync<T>(string url, T body, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(body, JsonOptions);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
return _httpClient.PatchAsync(url, content, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<HttpResponseMessage> DeleteAsync(string url, CancellationToken cancellationToken = default) =>
|
||||
_httpClient.DeleteAsync(url, cancellationToken);
|
||||
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
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 DetectionClassesTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions ResponseJsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private sealed record DetectionClassDto(int Id, string Name, string ShortName, string Color, double MaxSizeM, string? PhotoMode);
|
||||
|
||||
private readonly TestFixture _fixture;
|
||||
|
||||
public DetectionClassesTests(TestFixture fixture) => _fixture = fixture;
|
||||
|
||||
private static object NewClassBody(string nameSuffix) => new
|
||||
{
|
||||
name = $"Tank-{nameSuffix}",
|
||||
shortName = "T",
|
||||
color = "#FF0000",
|
||||
maxSizeM = 5.0
|
||||
};
|
||||
|
||||
private async Task<int> CreateClassAsync(ApiClient client, object body)
|
||||
{
|
||||
using var resp = await client.PostAsync("/classes", body);
|
||||
resp.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.Created);
|
||||
var dto = await resp.Content.ReadFromJsonAsync<DetectionClassDto>(ResponseJsonOptions);
|
||||
dto.Should().NotBeNull();
|
||||
dto!.Id.Should().BeGreaterThan(0);
|
||||
return dto.Id;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AC1_Post_classes_creates_class_with_assigned_id()
|
||||
{
|
||||
// Arrange
|
||||
using var client = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
|
||||
var suffix = Guid.NewGuid().ToString("N")[..8];
|
||||
var body = NewClassBody(suffix);
|
||||
int? createdId = null;
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
using var response = await client.PostAsync("/classes", body);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.Created);
|
||||
var dto = await response.Content.ReadFromJsonAsync<DetectionClassDto>(ResponseJsonOptions);
|
||||
dto.Should().NotBeNull();
|
||||
dto!.Id.Should().BeGreaterThan(0);
|
||||
dto.Name.Should().Be($"Tank-{suffix}");
|
||||
dto.ShortName.Should().Be("T");
|
||||
dto.Color.Should().Be("#FF0000");
|
||||
dto.MaxSizeM.Should().Be(5.0);
|
||||
createdId = dto.Id;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (createdId.HasValue)
|
||||
using (await client.DeleteAsync($"/classes/{createdId.Value}")) { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AC2_Post_classes_without_jwt_returns_401()
|
||||
{
|
||||
// Arrange
|
||||
using var client = _fixture.CreateApiClient();
|
||||
var body = NewClassBody(Guid.NewGuid().ToString("N")[..8]);
|
||||
|
||||
// Act
|
||||
using var response = await client.PostAsync("/classes", body);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AC2_Post_classes_with_non_admin_jwt_returns_403()
|
||||
{
|
||||
// Arrange
|
||||
var loginClient = _fixture.CreateApiClient();
|
||||
var uploaderToken = await loginClient.LoginAsync(_fixture.UploaderEmail, _fixture.UploaderPassword);
|
||||
loginClient.Dispose();
|
||||
|
||||
using var client = _fixture.CreateAuthenticatedClient(uploaderToken);
|
||||
var body = NewClassBody(Guid.NewGuid().ToString("N")[..8]);
|
||||
|
||||
// Act
|
||||
using var response = await client.PostAsync("/classes", body);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AC3_Patch_classes_full_body_updates_class()
|
||||
{
|
||||
// Arrange
|
||||
using var client = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
|
||||
var suffix = Guid.NewGuid().ToString("N")[..8];
|
||||
var id = await CreateClassAsync(client, NewClassBody(suffix));
|
||||
try
|
||||
{
|
||||
var patchBody = new
|
||||
{
|
||||
name = $"Heavy Tank-{suffix}",
|
||||
shortName = "T",
|
||||
color = "#FF0000",
|
||||
maxSizeM = 5.0
|
||||
};
|
||||
|
||||
// Act
|
||||
using var response = await client.PatchAsync($"/classes/{id}", patchBody);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var dto = await response.Content.ReadFromJsonAsync<DetectionClassDto>(ResponseJsonOptions);
|
||||
dto.Should().NotBeNull();
|
||||
dto!.Id.Should().Be(id);
|
||||
dto.Name.Should().Be($"Heavy Tank-{suffix}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
using (await client.DeleteAsync($"/classes/{id}")) { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AC4_Patch_classes_partial_body_only_updates_specified_field()
|
||||
{
|
||||
// Arrange
|
||||
using var client = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
|
||||
var suffix = Guid.NewGuid().ToString("N")[..8];
|
||||
var id = await CreateClassAsync(client, NewClassBody(suffix));
|
||||
try
|
||||
{
|
||||
var patchBody = new { color = "#00FF00" };
|
||||
|
||||
// Act
|
||||
using var response = await client.PatchAsync($"/classes/{id}", patchBody);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var dto = await response.Content.ReadFromJsonAsync<DetectionClassDto>(ResponseJsonOptions);
|
||||
dto.Should().NotBeNull();
|
||||
dto!.Color.Should().Be("#00FF00");
|
||||
dto.Name.Should().Be($"Tank-{suffix}");
|
||||
dto.ShortName.Should().Be("T");
|
||||
dto.MaxSizeM.Should().Be(5.0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
using (await client.DeleteAsync($"/classes/{id}")) { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AC5_Patch_classes_unknown_id_returns_404()
|
||||
{
|
||||
// Arrange
|
||||
using var client = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
|
||||
var patchBody = new { name = "Anything" };
|
||||
|
||||
// Act
|
||||
using var response = await client.PatchAsync("/classes/2147483600", patchBody);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AC6_Patch_classes_without_jwt_returns_401()
|
||||
{
|
||||
// Arrange
|
||||
using var client = _fixture.CreateApiClient();
|
||||
var patchBody = new { name = "Anything" };
|
||||
|
||||
// Act
|
||||
using var response = await client.PatchAsync("/classes/1", patchBody);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AC7_Delete_classes_removes_class()
|
||||
{
|
||||
// Arrange
|
||||
using var client = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
|
||||
var suffix = Guid.NewGuid().ToString("N")[..8];
|
||||
var id = await CreateClassAsync(client, NewClassBody(suffix));
|
||||
|
||||
// Act
|
||||
using var response = await client.DeleteAsync($"/classes/{id}");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent);
|
||||
|
||||
using var followup = await client.DeleteAsync($"/classes/{id}");
|
||||
followup.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AC8_Delete_classes_unknown_id_returns_404()
|
||||
{
|
||||
// Arrange
|
||||
using var client = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
|
||||
|
||||
// Act
|
||||
using var response = await client.DeleteAsync("/classes/2147483600");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AC9_Delete_classes_without_jwt_returns_401()
|
||||
{
|
||||
// Arrange
|
||||
using var client = _fixture.CreateApiClient();
|
||||
|
||||
// Act
|
||||
using var response = await client.DeleteAsync("/classes/1");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Azaion.E2E.Helpers;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace Azaion.E2E.Tests;
|
||||
|
||||
[Collection("E2E")]
|
||||
public sealed class DeviceRegistrationTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions ResponseJsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private static readonly Regex SerialPattern = new(@"^azj-\d{4}$", RegexOptions.Compiled);
|
||||
private static readonly Regex EmailPattern = new(@"^azj-\d{4}@azaion\.com$", RegexOptions.Compiled);
|
||||
|
||||
private sealed record RegisterDeviceResponseDto(string Serial, string Email, string Password);
|
||||
|
||||
private readonly TestFixture _fixture;
|
||||
|
||||
public DeviceRegistrationTests(TestFixture fixture) => _fixture = fixture;
|
||||
|
||||
private static string EmailPath(string email) => $"/users/{Uri.EscapeDataString(email)}";
|
||||
|
||||
[Fact]
|
||||
public async Task AC1_Post_devices_returns_serial_email_and_password()
|
||||
{
|
||||
// Arrange
|
||||
using var client = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
|
||||
string? createdEmail = null;
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
using var response = await client.PostAsync("/devices", new { });
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var dto = await response.Content.ReadFromJsonAsync<RegisterDeviceResponseDto>(ResponseJsonOptions);
|
||||
dto.Should().NotBeNull();
|
||||
SerialPattern.IsMatch(dto!.Serial).Should().BeTrue($"serial '{dto.Serial}' should match azj-NNNN");
|
||||
EmailPattern.IsMatch(dto.Email).Should().BeTrue($"email '{dto.Email}' should match azj-NNNN@azaion.com");
|
||||
dto.Password.Should().HaveLength(32);
|
||||
dto.Email.Should().StartWith(dto.Serial);
|
||||
createdEmail = dto.Email;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (createdEmail is not null)
|
||||
using (await client.DeleteAsync(EmailPath(createdEmail))) { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AC2_Sequential_device_serials_are_strictly_increasing()
|
||||
{
|
||||
// Arrange
|
||||
using var client = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
|
||||
var emails = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
using var first = await client.PostAsync("/devices", new { });
|
||||
first.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var firstDto = await first.Content.ReadFromJsonAsync<RegisterDeviceResponseDto>(ResponseJsonOptions);
|
||||
firstDto.Should().NotBeNull();
|
||||
emails.Add(firstDto!.Email);
|
||||
|
||||
using var second = await client.PostAsync("/devices", new { });
|
||||
second.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var secondDto = await second.Content.ReadFromJsonAsync<RegisterDeviceResponseDto>(ResponseJsonOptions);
|
||||
secondDto.Should().NotBeNull();
|
||||
emails.Add(secondDto!.Email);
|
||||
|
||||
// Assert
|
||||
var firstNumber = int.Parse(firstDto.Serial[4..]);
|
||||
var secondNumber = int.Parse(secondDto.Serial[4..]);
|
||||
secondNumber.Should().Be(firstNumber + 1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var email in emails)
|
||||
using (await client.DeleteAsync(EmailPath(email))) { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AC3_Returned_credentials_can_login()
|
||||
{
|
||||
// Arrange
|
||||
using var adminClient = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
|
||||
string? createdEmail = null;
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await adminClient.PostAsync("/devices", new { });
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var dto = await response.Content.ReadFromJsonAsync<RegisterDeviceResponseDto>(ResponseJsonOptions);
|
||||
dto.Should().NotBeNull();
|
||||
createdEmail = dto!.Email;
|
||||
|
||||
// Act
|
||||
using var loginClient = _fixture.CreateApiClient();
|
||||
var token = await loginClient.LoginAsync(dto.Email, dto.Password);
|
||||
|
||||
// Assert
|
||||
token.Should().NotBeNullOrWhiteSpace();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (createdEmail is not null)
|
||||
using (await adminClient.DeleteAsync(EmailPath(createdEmail))) { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AC4_Post_devices_without_jwt_returns_401()
|
||||
{
|
||||
// Arrange
|
||||
using var client = _fixture.CreateApiClient();
|
||||
|
||||
// Act
|
||||
using var response = await client.PostAsync("/devices", new { });
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AC4_Post_devices_with_non_admin_jwt_returns_403()
|
||||
{
|
||||
// Arrange
|
||||
var loginClient = _fixture.CreateApiClient();
|
||||
var uploaderToken = await loginClient.LoginAsync(_fixture.UploaderEmail, _fixture.UploaderPassword);
|
||||
loginClient.Dispose();
|
||||
|
||||
using var client = _fixture.CreateAuthenticatedClient(uploaderToken);
|
||||
|
||||
// Act
|
||||
using var response = await client.PostAsync("/devices", new { });
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
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<string> 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<string, string> { [resourceName] = "2026-02-25" }
|
||||
});
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var items = await response.Content.ReadFromJsonAsync<List<ResourceUpdateItemDto>>(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<string, string> { [resourceName] = "2026-04-13" }
|
||||
});
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var items = await response.Content.ReadFromJsonAsync<List<ResourceUpdateItemDto>>(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<string, string> { [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<List<ResourceUpdateItemDto>>(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<List<ResourceUpdateItemDto>>(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<string, string>()
|
||||
});
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user