feat(transport): DependencyResolver with topological closure

This commit is contained in:
Joseph Doherty
2026-05-24 04:19:23 -04:00
parent 550ab0e034
commit 06c2b20178
4 changed files with 669 additions and 1 deletions

View File

@@ -0,0 +1,217 @@
using NSubstitute;
using ScadaLink.Commons.Entities.ExternalSystems;
using ScadaLink.Commons.Entities.InboundApi;
using ScadaLink.Commons.Entities.Scripts;
using ScadaLink.Commons.Entities.Templates;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Types.Transport;
using ScadaLink.Transport.Export;
namespace ScadaLink.Transport.Tests.Export;
public sealed class DependencyResolverTests
{
private readonly ITemplateEngineRepository _templates = Substitute.For<ITemplateEngineRepository>();
private readonly IExternalSystemRepository _externalSystems = Substitute.For<IExternalSystemRepository>();
private readonly INotificationRepository _notifications = Substitute.For<INotificationRepository>();
private readonly IInboundApiRepository _inboundApi = Substitute.For<IInboundApiRepository>();
private DependencyResolver Sut() => new(_templates, _externalSystems, _notifications, _inboundApi);
private static ExportSelection SelectTemplates(params int[] ids) => new(
TemplateIds: ids,
SharedScriptIds: Array.Empty<int>(),
ExternalSystemIds: Array.Empty<int>(),
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: true);
private static ExportSelection SelectApiMethods(params int[] ids) => new(
TemplateIds: Array.Empty<int>(),
SharedScriptIds: Array.Empty<int>(),
ExternalSystemIds: Array.Empty<int>(),
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: ids,
IncludeDependencies: true);
private void StubTemplate(Template t)
{
_templates.GetTemplateWithChildrenAsync(t.Id, Arg.Any<CancellationToken>()).Returns(t);
}
private void StubAllSharedScripts(params SharedScript[] scripts)
{
_templates.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>()).Returns(scripts);
}
private void StubAllExternalSystems(params ExternalSystemDefinition[] systems)
{
_externalSystems.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>()).Returns(systems);
foreach (var es in systems)
{
_externalSystems
.GetMethodsByExternalSystemIdAsync(es.Id, Arg.Any<CancellationToken>())
.Returns(Array.Empty<ExternalSystemMethod>());
}
}
private void StubAllFolders(params TemplateFolder[] folders)
{
_templates.GetAllFoldersAsync(Arg.Any<CancellationToken>()).Returns(folders);
}
[Fact]
public async Task Resolve_includes_base_template_for_composed_template()
{
var baseT = new Template("Base") { Id = 10 };
var composing = new Template("Top") { Id = 11 };
composing.Compositions.Add(new TemplateComposition("slot") { TemplateId = 11, ComposedTemplateId = 10 });
StubTemplate(composing);
StubTemplate(baseT);
StubAllSharedScripts();
StubAllExternalSystems();
StubAllFolders();
var result = await Sut().ResolveAsync(SelectTemplates(11), CancellationToken.None);
Assert.Equal(2, result.Templates.Count);
Assert.Contains(result.Templates, t => t.Id == 10);
Assert.Contains(result.Templates, t => t.Id == 11);
}
[Fact]
public async Task Resolve_includes_shared_script_referenced_by_template()
{
var shared = new SharedScript("UtilHelper", "return 42;") { Id = 100 };
var t = new Template("UsesUtil") { Id = 1 };
t.Scripts.Add(new TemplateScript("body", "var x = UtilHelper(); return x;") { TemplateId = 1 });
StubTemplate(t);
StubAllSharedScripts(shared, new SharedScript("OtherScript", "return 0;") { Id = 101 });
StubAllExternalSystems();
StubAllFolders();
var result = await Sut().ResolveAsync(SelectTemplates(1), CancellationToken.None);
Assert.Single(result.SharedScripts);
Assert.Equal("UtilHelper", result.SharedScripts[0].Name);
}
[Fact]
public async Task Resolve_includes_external_system_referenced_by_template()
{
var es = new ExternalSystemDefinition("ErpSystem", "https://erp", "ApiKey") { Id = 7 };
var t = new Template("UsesErp") { Id = 2 };
t.Scripts.Add(new TemplateScript("call", "ErpSystem.Call(\"x\");") { TemplateId = 2 });
StubTemplate(t);
StubAllSharedScripts();
StubAllExternalSystems(es, new ExternalSystemDefinition("Other", "https://o", "Basic") { Id = 8 });
StubAllFolders();
var result = await Sut().ResolveAsync(SelectTemplates(2), CancellationToken.None);
Assert.Single(result.ExternalSystems);
Assert.Equal("ErpSystem", result.ExternalSystems[0].Name);
}
[Fact]
public async Task Resolve_includes_api_method_shared_script_dependency()
{
var shared = new SharedScript("Validator", "return true;") { Id = 50 };
var method = new ApiMethod("submit", "var ok = Validator(input); return ok;") { Id = 5 };
_inboundApi.GetApiMethodByIdAsync(5, Arg.Any<CancellationToken>()).Returns(method);
StubAllSharedScripts(shared);
StubAllExternalSystems();
StubAllFolders();
var result = await Sut().ResolveAsync(SelectApiMethods(5), CancellationToken.None);
Assert.Single(result.ApiMethods);
Assert.Single(result.SharedScripts);
Assert.Equal("Validator", result.SharedScripts[0].Name);
}
[Fact]
public async Task Resolve_handles_diamond_dependency_without_duplication()
{
// A composes B and C; both B and C compose D. Selection={A}. D must appear once.
var d = new Template("D") { Id = 4 };
var b = new Template("B") { Id = 2 };
b.Compositions.Add(new TemplateComposition("d-in-b") { TemplateId = 2, ComposedTemplateId = 4 });
var c = new Template("C") { Id = 3 };
c.Compositions.Add(new TemplateComposition("d-in-c") { TemplateId = 3, ComposedTemplateId = 4 });
var a = new Template("A") { Id = 1 };
a.Compositions.Add(new TemplateComposition("b-in-a") { TemplateId = 1, ComposedTemplateId = 2 });
a.Compositions.Add(new TemplateComposition("c-in-a") { TemplateId = 1, ComposedTemplateId = 3 });
StubTemplate(a);
StubTemplate(b);
StubTemplate(c);
StubTemplate(d);
StubAllSharedScripts();
StubAllExternalSystems();
StubAllFolders();
var result = await Sut().ResolveAsync(SelectTemplates(1), CancellationToken.None);
Assert.Equal(4, result.Templates.Count);
Assert.Single(result.Templates, t => t.Id == 4);
}
[Fact]
public async Task Resolve_includes_template_folder_for_each_selected_template()
{
var root = new TemplateFolder("Root") { Id = 1, ParentFolderId = null };
var child = new TemplateFolder("Child") { Id = 2, ParentFolderId = 1 };
var grand = new TemplateFolder("Grand") { Id = 3, ParentFolderId = 2 };
var t = new Template("T") { Id = 99, FolderId = 3 };
StubTemplate(t);
StubAllSharedScripts();
StubAllExternalSystems();
StubAllFolders(root, child, grand,
new TemplateFolder("Unrelated") { Id = 4, ParentFolderId = null });
var result = await Sut().ResolveAsync(SelectTemplates(99), CancellationToken.None);
Assert.Equal(3, result.TemplateFolders.Count);
// Root-first ordering: depth 0, 1, 2.
Assert.Equal("Root", result.TemplateFolders[0].Name);
Assert.Equal("Child", result.TemplateFolders[1].Name);
Assert.Equal("Grand", result.TemplateFolders[2].Name);
}
[Fact]
public async Task Resolve_returns_topological_order_base_before_derived()
{
// Top composes Middle, Middle composes Leaf. Order must be Leaf, Middle, Top.
var leaf = new Template("Leaf") { Id = 30 };
var middle = new Template("Middle") { Id = 20 };
middle.Compositions.Add(new TemplateComposition("l") { TemplateId = 20, ComposedTemplateId = 30 });
var top = new Template("Top") { Id = 10 };
top.Compositions.Add(new TemplateComposition("m") { TemplateId = 10, ComposedTemplateId = 20 });
StubTemplate(top);
StubTemplate(middle);
StubTemplate(leaf);
StubAllSharedScripts();
StubAllExternalSystems();
StubAllFolders();
var result = await Sut().ResolveAsync(SelectTemplates(10), CancellationToken.None);
Assert.Equal(3, result.Templates.Count);
Assert.Equal("Leaf", result.Templates[0].Name);
Assert.Equal("Middle", result.Templates[1].Name);
Assert.Equal("Top", result.Templates[2].Name);
}
}