using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using ScadaLink.Commons.Entities.Templates; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Interfaces.Transport; using ScadaLink.Commons.Types.Enums; using ScadaLink.Commons.Types.Transport; using ScadaLink.ConfigurationDatabase; using ScadaLink.ConfigurationDatabase.Repositories; using ScadaLink.ConfigurationDatabase.Services; using ScadaLink.Transport; using ScadaLink.Transport.Import; namespace ScadaLink.Transport.IntegrationTests; /// /// FU-C — integration tests for the two-tier semantic validation wired into /// : Pass 1 is the minimal name- /// resolution scan (carried forward from the v1 importer) and Pass 2 is the /// full SemanticValidator over each imported template's /// FlattenedConfiguration. Pass 1 fails fast — Pass 2 only runs when /// Pass 1 succeeds — so the Pass 2 scenarios here are chosen to live entirely /// in alarm shape (alarm JSON is not scanned by Pass 1). /// /// The "invalid call target" test exercises Pass 1 because every /// SemanticValidator call-target rule presupposes the called identifier is /// already known to the script body's surface; an entirely-unknown identifier /// surfaces at Pass 1 first by design. Both tiers throw the same /// with errors propagated. /// /// public sealed class SemanticValidatorImportTests : IDisposable { private readonly ServiceProvider _provider; public SemanticValidatorImportTests() { var services = new ServiceCollection(); services.AddSingleton( new ConfigurationBuilder().AddInMemoryCollection().Build()); var dbName = $"SemanticValidatorImportTests_{Guid.NewGuid()}"; services.AddDbContext(opts => opts .UseInMemoryDatabase(dbName) .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddTransport(); _provider = services.BuildServiceProvider(); } public void Dispose() => _provider.Dispose(); /// /// Export everything currently seeded, wipe the DB, then LoadAsync the /// bundle. Returns the session id. Mirrors the helper in /// BundleImporterApplyTests but exported as a free helper so each /// test can seed its own template shape without sharing fixture state. /// private async Task ExportWipeAndLoadAsync() { byte[] bundleBytes; await using (var scope = _provider.CreateAsyncScope()) { var exporter = scope.ServiceProvider.GetRequiredService(); var ctx = scope.ServiceProvider.GetRequiredService(); var ids = await ctx.Templates.Select(t => t.Id).ToListAsync(); var selection = new ExportSelection( TemplateIds: ids, SharedScriptIds: Array.Empty(), ExternalSystemIds: Array.Empty(), DatabaseConnectionIds: Array.Empty(), NotificationListIds: Array.Empty(), SmtpConfigurationIds: Array.Empty(), ApiKeyIds: Array.Empty(), ApiMethodIds: Array.Empty(), IncludeDependencies: false); var stream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev", passphrase: null, cancellationToken: CancellationToken.None); using var ms = new MemoryStream(); await stream.CopyToAsync(ms); bundleBytes = ms.ToArray(); } // Wipe so the apply is exercising the Add path. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); ctx.TemplateAlarms.RemoveRange(ctx.TemplateAlarms); ctx.TemplateScripts.RemoveRange(ctx.TemplateScripts); ctx.TemplateAttributes.RemoveRange(ctx.TemplateAttributes); ctx.Templates.RemoveRange(ctx.Templates); await ctx.SaveChangesAsync(); } Guid sessionId; await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); using var input = new MemoryStream(bundleBytes, writable: false); var session = await importer.LoadAsync(input, passphrase: null); sessionId = session.SessionId; } return sessionId; } [Fact] public async Task SemanticValidator_catches_invalid_call_target_at_import() { // Arrange — template whose script body calls UnknownHelper(): a // PascalCase identifier that doesn't resolve to any SharedScript or // ExternalSystem in the bundle or the target. This is the operator- // facing "invalid call target" surface — the full SemanticValidator's // CallScript/CallShared signature checks live downstream of name // resolution (you can't check arg count against a function that // doesn't exist). Pass 1 catches it first and fails fast. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var t = new Template("ScriptCallsUnknown"); t.Scripts.Add(new Commons.Entities.Templates.TemplateScript( "init", "var x = UnknownHelper();")); ctx.Templates.Add(t); await ctx.SaveChangesAsync(); } var sessionId = await ExportWipeAndLoadAsync(); // Act — apply must throw SemanticValidationException carrying the bad // call target by name. SemanticValidationException ex = default!; await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); ex = await Assert.ThrowsAsync(() => importer.ApplyAsync(sessionId, new List { new("Template", "ScriptCallsUnknown", ResolutionAction.Add, null), }, user: "bob")); } // Assert — error message names the bad target. Assert.NotEmpty(ex.Errors); Assert.Contains(ex.Errors, err => err.Contains("UnknownHelper", StringComparison.Ordinal)); // Rollback — no template row landed. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); Assert.False(await ctx.Templates.AnyAsync(t => t.Name == "ScriptCallsUnknown")); } } [Fact] public async Task SemanticValidator_catches_alarm_trigger_type_mismatch_at_import() { // Arrange — template with a String attribute Status and a // RangeViolation alarm against it. The full SemanticValidator must // report TriggerOperandType (RangeViolation requires numeric). // Pass 1 doesn't scan alarm JSON, so the error reaches Pass 2. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var t = new Template("TankWithBadAlarm") { Description = "RangeViolation on string attr" }; t.Attributes.Add(new TemplateAttribute("Status") { DataType = DataType.String, Value = "OK", }); t.Alarms.Add(new TemplateAlarm("BadRange") { TriggerType = AlarmTriggerType.RangeViolation, TriggerConfiguration = "{\"attributeName\":\"Status\",\"min\":0,\"max\":100}", PriorityLevel = 1, }); ctx.Templates.Add(t); await ctx.SaveChangesAsync(); } var sessionId = await ExportWipeAndLoadAsync(); // Act SemanticValidationException ex = default!; await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); ex = await Assert.ThrowsAsync(() => importer.ApplyAsync(sessionId, new List { new("Template", "TankWithBadAlarm", ResolutionAction.Add, null), }, user: "bob")); } // Assert — error names the offending alarm and the bad trigger // type so the operator can locate the fix. Assert.NotEmpty(ex.Errors); Assert.Contains(ex.Errors, err => err.Contains("BadRange", StringComparison.Ordinal) && err.Contains("RangeViolation", StringComparison.Ordinal)); // Rollback — no template row landed. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); Assert.False(await ctx.Templates.AnyAsync(t => t.Name == "TankWithBadAlarm")); } } [Fact] public async Task Valid_bundle_passes_semantic_validation() { // Arrange — clean template that satisfies both passes: one Double // attribute, one ValueMatch alarm on it, one script with no external // call identifiers. ValueMatch doesn't constrain the operand data // type (only RangeViolation / HiLo do), so this template's alarm is // legal. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var t = new Template("CleanPump") { Description = "passes both passes" }; t.Attributes.Add(new TemplateAttribute("Speed") { DataType = DataType.Double, Value = "0", }); t.Alarms.Add(new TemplateAlarm("Overspeed") { TriggerType = AlarmTriggerType.ValueMatch, TriggerConfiguration = "{\"attributeName\":\"Speed\",\"value\":100}", PriorityLevel = 1, }); t.Scripts.Add(new Commons.Entities.Templates.TemplateScript( "tick", "// no external calls")); ctx.Templates.Add(t); await ctx.SaveChangesAsync(); } var sessionId = await ExportWipeAndLoadAsync(); // Act — happy-path import. ImportResult result; await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); result = await importer.ApplyAsync(sessionId, new List { new("Template", "CleanPump", ResolutionAction.Add, null), }, user: "bob"); } // Assert — template + alarm survived the round-trip. Assert.Equal(1, result.Added); await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); Assert.True(await ctx.Templates.AnyAsync(t => t.Name == "CleanPump")); Assert.True(await ctx.TemplateAlarms.AnyAsync(a => a.Name == "Overspeed")); } } }