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;
///
/// ManagementActor handler tests for the M9-T32c schema-library CRUD commands. The
/// reusable named JSON-Schema library ( +
/// , 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).
///
public class SchemaLibraryHandlerTests : TestKit, IDisposable
{
private readonly ISharedSchemaRepository _schemaRepo;
private readonly IAuditService _auditService;
private readonly ServiceCollection _services;
public SchemaLibraryHandlerTests()
{
_schemaRepo = Substitute.For();
_auditService = Substitute.For();
_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.Instance)));
}
private static ManagementEnvelope Envelope(object command, params string[] roles) =>
new(new AuthenticatedUser("testuser", "Test User", roles, Array.Empty()),
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(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(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(TimeSpan.FromSeconds(5));
Assert.Contains("Designer", response.Message);
}
// ── List / Get (read-gated) ─────────────────────────────────────────────
[Fact]
public void ListSharedSchemas_WithNoRoles_ReturnsSuccess()
{
_schemaRepo.ListAsync(Arg.Any())
.Returns(new List
{
new() { Id = 1, Name = "Addr", SchemaJson = ValidSchema },
});
var actor = CreateActor();
var envelope = Envelope(new ListSharedSchemasCommand());
actor.Tell(envelope);
var response = ExpectMsg(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())
.Returns(new SharedSchema { Id = 7, Name = "Addr", SchemaJson = ValidSchema });
var actor = CreateActor();
var envelope = Envelope(new GetSharedSchemaCommand(7));
actor.Tell(envelope);
var response = ExpectMsg(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())
.Returns((SharedSchema?)null);
_schemaRepo.AddAsync(Arg.Any(), Arg.Any())
.Returns(ci => { saved = ci.Arg(); saved.Id = 42; return 42; });
var actor = CreateActor();
var envelope = Envelope(
new CreateSharedSchemaCommand("Addr", "global", ValidSchema), "Designer");
actor.Tell(envelope);
var response = ExpectMsg(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())
.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(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(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(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(), Arg.Any());
}
// ── 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()).Returns(existing);
_schemaRepo.GetByNameAsync("Address", Arg.Any())
.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(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())
.Returns((SharedSchema?)null);
var actor = CreateActor();
var envelope = Envelope(
new UpdateSharedSchemaCommand(99, "Addr", null, ValidSchema), "Designer");
actor.Tell(envelope);
var response = ExpectMsg(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()).Returns(existing);
// A DIFFERENT row already owns "Other".
_schemaRepo.GetByNameAsync("Other", Arg.Any())
.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(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()).Returns(existing);
// The unique-name check finds THIS row — must not be treated as a collision.
_schemaRepo.GetByNameAsync("Addr", Arg.Any()).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(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(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
_schemaRepo.Received(1).DeleteAsync(5, Arg.Any());
}
[Fact]
public void ListSharedSchemas_WhenRepoThrows_ReturnsError()
{
_schemaRepo.ListAsync(Arg.Any())
.ThrowsAsync(new InvalidOperationException("db down secret detail"));
var actor = CreateActor();
var envelope = Envelope(new ListSharedSchemasCommand());
actor.Tell(envelope);
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.DoesNotContain("db down secret detail", response.Error);
}
}