using Microsoft.EntityFrameworkCore; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests; public class TemplateEngineRepositoryTests : IDisposable { private readonly ScadaBridgeDbContext _context; private readonly TemplateEngineRepository _repository; /// Initializes a new instance of the class. public TemplateEngineRepositoryTests() { var options = new DbContextOptionsBuilder() .UseSqlite("DataSource=:memory:") .Options; _context = new ScadaBridgeDbContext(options); _context.Database.OpenConnection(); _context.Database.EnsureCreated(); _repository = new TemplateEngineRepository(_context, new TemplateGraphWatermark()); } /// Closes the connection and disposes the database context. public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } /// Verifies that the topology page is bounded, filtered and returned untracked. /// A task that represents the asynchronous operation. [Fact] public async Task TopologyPage_IsBoundedFilteredAndUntracked() { var site = new ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site("PageSite", "page-site"); var other = new ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site("OtherPageSite", "other-page-site"); var template = new Template("PageTemplate"); _context.Sites.AddRange(site, other); _context.Templates.Add(template); await _context.SaveChangesAsync(); _context.Instances.AddRange(Enumerable.Range(0, 101).Select(id => new ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance($"Paged-{id:D3}") { SiteId = site.Id, TemplateId = template.Id })); _context.Instances.Add(new ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance("Other") { SiteId = other.Id, TemplateId = template.Id }); await _context.SaveChangesAsync(); _context.ChangeTracker.Clear(); var first = await _repository.GetTopologyInstancesAsync(site.Id, null, 0, 50); var second = await _repository.GetTopologyInstancesAsync(site.Id, null, 50, 50); Assert.Equal(50, first.Count); Assert.Equal(50, second.Count); Assert.All(first.Concat(second), i => Assert.Equal(site.Id, i.SiteId)); Assert.Empty(first.Select(i => i.Id).Intersect(second.Select(i => i.Id))); Assert.Empty(_context.ChangeTracker.Entries()); await Assert.ThrowsAsync(() => _repository.GetTopologyInstancesAsync(site.Id, null, 0, 51)); } /// Verifies that loading a template with children returns it with all member collections populated. /// A task that represents the asynchronous operation. [Fact] public async Task GetTemplateWithChildrenAsync_ReturnsTemplateWithAllMemberCollectionsPopulated() { // Arrange: a template with one attribute, one alarm, one script and one composition. var composed = new Template("ComposedTemplate"); _context.Templates.Add(composed); await _context.SaveChangesAsync(); var template = new Template("ParentTemplate"); template.Attributes.Add(new TemplateAttribute("Attr1")); template.Alarms.Add(new TemplateAlarm("Alarm1")); template.Scripts.Add(new TemplateScript("Script1", "return 1;")); template.Compositions.Add(new TemplateComposition("Slot1") { ComposedTemplateId = composed.Id }); _context.Templates.Add(template); await _context.SaveChangesAsync(); // Act var loaded = await _repository.GetTemplateWithChildrenAsync(template.Id); // Assert: the method must deliver the template's child members to the caller, // not silently drop them. Regression guard for dropped child members. Assert.NotNull(loaded); Assert.Equal(template.Id, loaded!.Id); Assert.Single(loaded.Attributes); Assert.Equal("Attr1", loaded.Attributes.First().Name); Assert.Single(loaded.Alarms); Assert.Equal("Alarm1", loaded.Alarms.First().Name); Assert.Single(loaded.Scripts); Assert.Equal("Script1", loaded.Scripts.First().Name); Assert.Single(loaded.Compositions); Assert.Equal("Slot1", loaded.Compositions.First().InstanceName); } /// Verifies that a template script's execution timeout in seconds round-trips through EF. /// A task that represents the asynchronous operation. [Fact] public async Task TemplateScript_ExecutionTimeoutSeconds_RoundTripsThroughEf() { // The nullable per-script execution timeout must persist and // reload through EF — both an explicit value and a null (use-global). var template = new Template("TimeoutTemplate"); template.Scripts.Add(new TemplateScript("WithTimeout", "return 1;") { ExecutionTimeoutSeconds = 45 }); template.Scripts.Add(new TemplateScript("NoTimeout", "return 2;")); // null _context.Templates.Add(template); await _context.SaveChangesAsync(); // Detach so the reload comes from the store, not the change tracker. _context.ChangeTracker.Clear(); var loaded = await _context.Templates .Include(t => t.Scripts) .SingleAsync(t => t.Name == "TimeoutTemplate"); var withTimeout = loaded.Scripts.Single(s => s.Name == "WithTimeout"); Assert.Equal(45, withTimeout.ExecutionTimeoutSeconds); var noTimeout = loaded.Scripts.Single(s => s.Name == "NoTimeout"); Assert.Null(noTimeout.ExecutionTimeoutSeconds); } /// Verifies that loading a template with children returns null when the template does not exist. /// A task that represents the asynchronous operation. [Fact] public async Task GetTemplateWithChildrenAsync_ReturnsNull_WhenTemplateDoesNotExist() { var loaded = await _repository.GetTemplateWithChildrenAsync(9999); Assert.Null(loaded); } /// Verifies that the bulk variant fetches every matching template name in one query. /// A task that represents the asynchronous operation. [Fact] public async Task GetTemplatesWithChildrenAsync_BulkVariant_FetchesEveryMatchingNameInOneQuery() { // Regression: BundleImporter.PreviewAsync previously // called GetTemplateWithChildrenAsync(stub.Id) per matching template // name. The bulk variant returns every match in a single query. var a = new Template("Alpha"); a.Attributes.Add(new TemplateAttribute("A1")); a.Scripts.Add(new TemplateScript("AS1", "return 1;")); var b = new Template("Beta"); b.Alarms.Add(new TemplateAlarm("BAlarm")); var c = new Template("Gamma"); _context.Templates.AddRange(a, b, c); await _context.SaveChangesAsync(); var result = await _repository.GetTemplatesWithChildrenAsync(new[] { "Alpha", "Beta", "DoesNotExist" }); Assert.Equal(2, result.Count); var loadedA = Assert.Single(result, t => t.Name == "Alpha"); var loadedB = Assert.Single(result, t => t.Name == "Beta"); Assert.Single(loadedA.Attributes); Assert.Equal("A1", loadedA.Attributes.First().Name); Assert.Single(loadedA.Scripts); Assert.Single(loadedB.Alarms); Assert.Equal("BAlarm", loadedB.Alarms.First().Name); Assert.DoesNotContain(result, t => t.Name == "Gamma"); } /// Verifies that the bulk variant returns an empty result for an empty list of names. /// A task that represents the asynchronous operation. [Fact] public async Task GetTemplatesWithChildrenAsync_EmptyNames_ReturnsEmpty() { var result = await _repository.GetTemplatesWithChildrenAsync(Array.Empty()); Assert.Empty(result); } /// Verifies that the bulk variant returns an empty result for a null enumerable. /// A task that represents the asynchronous operation. [Fact] public async Task GetTemplatesWithChildrenAsync_NullEnumerable_ReturnsEmpty() { var result = await _repository.GetTemplatesWithChildrenAsync(null!); Assert.Empty(result); } /// Verifies that the bulk variant filters out duplicate and empty names. /// A task that represents the asynchronous operation. [Fact] public async Task GetTemplatesWithChildrenAsync_FiltersOutDuplicatesAndEmptyStrings() { var a = new Template("Alpha"); _context.Templates.Add(a); await _context.SaveChangesAsync(); // Duplicate names + empty / null entries should not throw; the helper // deduplicates and filters them out before the SQL IN clause. var result = await _repository.GetTemplatesWithChildrenAsync( new[] { "Alpha", "Alpha", "", null!, "Alpha" }); Assert.Single(result); Assert.Equal("Alpha", result[0].Name); } /// Verifies that the parent template id is preserved so the inheritance chain can be walked. /// A task that represents the asynchronous operation. [Fact] public async Task GetTemplateWithChildrenAsync_PreservesParentTemplateId_ForInheritanceChainWalk() { // FlatteningPipeline.BuildTemplateChainAsync walks ParentTemplateId upward. // The result of GetTemplateWithChildrenAsync must carry that link intact. var baseTemplate = new Template("BaseTemplate"); _context.Templates.Add(baseTemplate); await _context.SaveChangesAsync(); var derived = new Template("DerivedTemplate") { ParentTemplateId = baseTemplate.Id }; _context.Templates.Add(derived); await _context.SaveChangesAsync(); var loaded = await _repository.GetTemplateWithChildrenAsync(derived.Id); Assert.NotNull(loaded); Assert.Equal(baseTemplate.Id, loaded!.ParentTemplateId); } /// Verifies that native alarm sources added for a template round-trip when queried by template id. /// A task that represents the asynchronous operation. [Fact] public async Task AddAndGetNativeAlarmSourcesByTemplateId_RoundTrips() { var t = new Template("T"); _context.Templates.Add(t); await _context.SaveChangesAsync(); await _repository.AddTemplateNativeAlarmSourceAsync( new TemplateNativeAlarmSource("S") { TemplateId = t.Id, ConnectionName = "C", SourceReference = "r" }); await _context.SaveChangesAsync(); var list = await _repository.GetNativeAlarmSourcesByTemplateIdAsync(t.Id); Assert.Single(list); Assert.Equal("S", list[0].Name); } /// Verifies that loading a template with children includes its native alarm sources. /// A task that represents the asynchronous operation. [Fact] public async Task GetTemplateWithChildren_IncludesNativeAlarmSources() { var t = new Template("T"); t.NativeAlarmSources.Add(new TemplateNativeAlarmSource("S") { ConnectionName = "C", SourceReference = "r" }); _context.Templates.Add(t); await _context.SaveChangesAsync(); var loaded = await _repository.GetTemplateWithChildrenAsync(t.Id); Assert.Single(loaded!.NativeAlarmSources); } }