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 schema-library CRUD commands. The
/// reusable named JSON-Schema library ( +
/// ) 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;
/// Builds the management actor handler over a mocked schema repository.
public SchemaLibraryHandlerTests()
{
_schemaRepo = Substitute.For();
_auditService = Substitute.For();
_services = new ServiceCollection().AddTestConfigurationTransaction();
_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 ──────────────────────────────────────────────────────────────
/// Verifies that creating a shared schema with only the Deployer role returns unauthorized.
[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);
}
/// Verifies that updating a shared schema with no roles returns unauthorized.
[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);
}
/// Verifies that deleting a shared schema with only the Deployer role returns unauthorized.
[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) ─────────────────────────────────────────────
/// Verifies that listing shared schemas succeeds for a user with no roles.
[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);
}
/// Verifies that getting a shared schema succeeds for a user with no roles.
[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 ──────────────────────────────────────
/// Verifies that creating a shared schema with the Designer role persists it and returns the entity.
[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);
}
/// Verifies that creating a shared schema with a duplicate name returns a clear error.
[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);
}
/// Verifies that creating a shared schema with an empty name returns a clear 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);
}
/// Verifies that creating a shared schema with invalid JSON returns a clear error.
[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 ──────────────────────────────────────
/// Verifies that updating a shared schema with the Designer role persists the changes.
[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);
}
/// Verifies that updating a shared schema that does not exist returns a clear error.
[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);
}
/// Verifies that renaming a shared schema to an existing name returns a clear 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);
}
/// Verifies that updating a shared schema while keeping its own name is allowed.
[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 ───────────────────────────────────────────────────
/// Verifies that deleting a shared schema with the Designer role deletes it.
[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());
}
/// Verifies that deleting a shared schema records its human-readable name in the audit entry.
[Fact]
public void DeleteSharedSchema_RecordsHumanReadableNameInAudit()
{
// The delete handler pre-fetches the row name BEFORE deleting so the
// audit EntityName records "Addr" rather than the numeric id (mirroring the
// Site delete handler).
_schemaRepo.GetByIdAsync(5, Arg.Any())
.Returns(new SharedSchema { Id = 5, Name = "Addr", SchemaJson = ValidSchema });
var actor = CreateActor();
var envelope = Envelope(new DeleteSharedSchemaCommand(5), "Designer");
actor.Tell(envelope);
ExpectMsg(TimeSpan.FromSeconds(5));
// LogAsync(user, action, entityType, entityId, entityName, afterState, ct):
// entityId is the id ("5"), entityName is the resolved name ("Addr").
_auditService.Received(1).LogAsync(
Arg.Any(), "Delete", "SharedSchema", "5", "Addr",
Arg.Any