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); } }