mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 15:01:09 +00:00
5ca9ccab2c
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>
240 lines
7.6 KiB
C#
240 lines
7.6 KiB
C#
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);
|
|
}
|
|
}
|