feat(m9/T32c): schema-library CRUD commands + handlers + Central UI page + read-accessor
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
using System.Text.Json;
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.ManagementService;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// ManagementActor handler tests for the M9-T32c schema-library CRUD commands. The
|
||||
/// reusable named JSON-Schema library (<see cref="SharedSchema"/> +
|
||||
/// <see cref="ISharedSchemaRepository"/>, T32a) gains its authoring surface here:
|
||||
/// list/get are read-only (any authenticated user), create/update/delete are
|
||||
/// Designer-gated, the schema JSON is validated as a JSON Schema, and the unique
|
||||
/// Name is enforced with a clear duplicate-name error. Mirrors the repo-backed
|
||||
/// DatabaseConnection handler idiom (direct repo writes + audit).
|
||||
/// </summary>
|
||||
public class SchemaLibraryHandlerTests : TestKit, IDisposable
|
||||
{
|
||||
private readonly ISharedSchemaRepository _schemaRepo;
|
||||
private readonly IAuditService _auditService;
|
||||
private readonly ServiceCollection _services;
|
||||
|
||||
public SchemaLibraryHandlerTests()
|
||||
{
|
||||
_schemaRepo = Substitute.For<ISharedSchemaRepository>();
|
||||
_auditService = Substitute.For<IAuditService>();
|
||||
|
||||
_services = new ServiceCollection();
|
||||
_services.AddScoped(_ => _schemaRepo);
|
||||
_services.AddScoped(_ => _auditService);
|
||||
}
|
||||
|
||||
private IActorRef CreateActor()
|
||||
{
|
||||
var sp = _services.BuildServiceProvider();
|
||||
return Sys.ActorOf(Props.Create(() => new ManagementActor(
|
||||
sp, NullLogger<ManagementActor>.Instance)));
|
||||
}
|
||||
|
||||
private static ManagementEnvelope Envelope(object command, params string[] roles) =>
|
||||
new(new AuthenticatedUser("testuser", "Test User", roles, Array.Empty<string>()),
|
||||
command, Guid.NewGuid().ToString("N"));
|
||||
|
||||
void IDisposable.Dispose() => Shutdown();
|
||||
|
||||
private const string ValidSchema =
|
||||
"""{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}""";
|
||||
|
||||
// ── Gating ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void CreateSharedSchema_WithDeployerRole_ReturnsUnauthorized()
|
||||
{
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new CreateSharedSchemaCommand("Addr", null, ValidSchema), "Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("Designer", response.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateSharedSchema_WithNoRoles_ReturnsUnauthorized()
|
||||
{
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new UpdateSharedSchemaCommand(1, "Addr", null, ValidSchema));
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("Designer", response.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteSharedSchema_WithDeployerRole_ReturnsUnauthorized()
|
||||
{
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new DeleteSharedSchemaCommand(1), "Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("Designer", response.Message);
|
||||
}
|
||||
|
||||
// ── List / Get (read-gated) ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ListSharedSchemas_WithNoRoles_ReturnsSuccess()
|
||||
{
|
||||
_schemaRepo.ListAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<SharedSchema>
|
||||
{
|
||||
new() { Id = 1, Name = "Addr", SchemaJson = ValidSchema },
|
||||
});
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new ListSharedSchemasCommand());
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
Assert.Contains("Addr", response.JsonData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSharedSchema_WithNoRoles_ReturnsSuccess()
|
||||
{
|
||||
_schemaRepo.GetByIdAsync(7, Arg.Any<CancellationToken>())
|
||||
.Returns(new SharedSchema { Id = 7, Name = "Addr", SchemaJson = ValidSchema });
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new GetSharedSchemaCommand(7));
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("Addr", response.JsonData);
|
||||
}
|
||||
|
||||
// ── Create round-trip + validation ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void CreateSharedSchema_WithDesignerRole_PersistsAndReturnsEntity()
|
||||
{
|
||||
SharedSchema? saved = null;
|
||||
_schemaRepo.GetByNameAsync("Addr", Arg.Any<CancellationToken>())
|
||||
.Returns((SharedSchema?)null);
|
||||
_schemaRepo.AddAsync(Arg.Any<SharedSchema>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => { saved = ci.Arg<SharedSchema>(); saved.Id = 42; return 42; });
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new CreateSharedSchemaCommand("Addr", "global", ValidSchema), "Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
Assert.NotNull(saved);
|
||||
Assert.Equal("Addr", saved!.Name);
|
||||
Assert.Equal("global", saved.Scope);
|
||||
Assert.Equal(ValidSchema, saved.SchemaJson);
|
||||
// Returned payload carries the store-generated id.
|
||||
Assert.Contains("42", response.JsonData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSharedSchema_WithDuplicateName_ReturnsClearError()
|
||||
{
|
||||
_schemaRepo.GetByNameAsync("Addr", Arg.Any<CancellationToken>())
|
||||
.Returns(new SharedSchema { Id = 1, Name = "Addr", SchemaJson = ValidSchema });
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new CreateSharedSchemaCommand("Addr", null, ValidSchema), "Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||
Assert.Contains("Addr", response.Error);
|
||||
Assert.Contains("already exists", response.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSharedSchema_WithEmptyName_ReturnsClearError()
|
||||
{
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new CreateSharedSchemaCommand(" ", null, ValidSchema), "Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||
Assert.Contains("name", response.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSharedSchema_WithInvalidJson_ReturnsClearError()
|
||||
{
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new CreateSharedSchemaCommand("Addr", null, "{ not valid json"), "Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||
Assert.Contains("schema", response.Error, StringComparison.OrdinalIgnoreCase);
|
||||
// The repo must never be hit when the schema fails validation.
|
||||
_schemaRepo.DidNotReceive().AddAsync(Arg.Any<SharedSchema>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ── Update round-trip + validation ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void UpdateSharedSchema_WithDesignerRole_PersistsChanges()
|
||||
{
|
||||
var existing = new SharedSchema { Id = 5, Name = "Addr", Scope = null, SchemaJson = ValidSchema };
|
||||
_schemaRepo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(existing);
|
||||
_schemaRepo.GetByNameAsync("Address", Arg.Any<CancellationToken>())
|
||||
.Returns((SharedSchema?)null);
|
||||
|
||||
const string updatedSchema =
|
||||
"""{"type":"object","properties":{"city":{"type":"string"}}}""";
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new UpdateSharedSchemaCommand(5, "Address", "us", updatedSchema), "Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
Assert.Equal("Address", existing.Name);
|
||||
Assert.Equal("us", existing.Scope);
|
||||
Assert.Equal(updatedSchema, existing.SchemaJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateSharedSchema_NotFound_ReturnsClearError()
|
||||
{
|
||||
_schemaRepo.GetByIdAsync(99, Arg.Any<CancellationToken>())
|
||||
.Returns((SharedSchema?)null);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new UpdateSharedSchemaCommand(99, "Addr", null, ValidSchema), "Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||
Assert.Contains("99", response.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateSharedSchema_RenameToExistingName_ReturnsClearError()
|
||||
{
|
||||
var existing = new SharedSchema { Id = 5, Name = "Addr", SchemaJson = ValidSchema };
|
||||
_schemaRepo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(existing);
|
||||
// A DIFFERENT row already owns "Other".
|
||||
_schemaRepo.GetByNameAsync("Other", Arg.Any<CancellationToken>())
|
||||
.Returns(new SharedSchema { Id = 6, Name = "Other", SchemaJson = ValidSchema });
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new UpdateSharedSchemaCommand(5, "Other", null, ValidSchema), "Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||
Assert.Contains("already exists", response.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateSharedSchema_SameName_IsAllowed()
|
||||
{
|
||||
var existing = new SharedSchema { Id = 5, Name = "Addr", SchemaJson = ValidSchema };
|
||||
_schemaRepo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(existing);
|
||||
// The unique-name check finds THIS row — must not be treated as a collision.
|
||||
_schemaRepo.GetByNameAsync("Addr", Arg.Any<CancellationToken>()).Returns(existing);
|
||||
|
||||
const string updatedSchema =
|
||||
"""{"type":"object","properties":{"zip":{"type":"string"}}}""";
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new UpdateSharedSchemaCommand(5, "Addr", null, updatedSchema), "Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(updatedSchema, existing.SchemaJson);
|
||||
}
|
||||
|
||||
// ── Delete round-trip ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void DeleteSharedSchema_WithDesignerRole_Deletes()
|
||||
{
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new DeleteSharedSchemaCommand(5), "Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
_schemaRepo.Received(1).DeleteAsync(5, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListSharedSchemas_WhenRepoThrows_ReturnsError()
|
||||
{
|
||||
_schemaRepo.ListAsync(Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException("db down secret detail"));
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new ListSharedSchemasCommand());
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||
Assert.DoesNotContain("db down secret detail", response.Error);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user