diff --git a/src/ScadaLink.Transport/Export/DependencyResolver.cs b/src/ScadaLink.Transport/Export/DependencyResolver.cs
new file mode 100644
index 0000000..7822fd4
--- /dev/null
+++ b/src/ScadaLink.Transport/Export/DependencyResolver.cs
@@ -0,0 +1,421 @@
+using ScadaLink.Commons.Entities.ExternalSystems;
+using ScadaLink.Commons.Entities.InboundApi;
+using ScadaLink.Commons.Entities.Notifications;
+using ScadaLink.Commons.Entities.Scripts;
+using ScadaLink.Commons.Entities.Templates;
+using ScadaLink.Commons.Interfaces.Repositories;
+using ScadaLink.Commons.Types.Transport;
+
+namespace ScadaLink.Transport.Export;
+
+///
+/// Expands an into the full set of entities that
+/// must be in the bundle for the selection to be self-consistent. Walks the
+/// dependency edges documented in §6.1 of the Transport design doc:
+///
+/// - Template composes Template (via TemplateComposition.ComposedTemplateId).
+/// - Template references SharedScript (by name, scanned in script bodies).
+/// - Template references ExternalSystem (by name, scanned in script bodies + attribute DataSourceReference).
+/// - ApiMethod references SharedScript (by name, scanned in ApiMethod.Script).
+/// - Selected Template's folder chain (ancestor folders included).
+/// - ExternalSystemDefinition pulls its ExternalSystemMethods.
+///
+/// Templates are returned topologically sorted (base-before-derived) via Kahn's
+/// algorithm so importers can apply them in order.
+///
+public sealed class DependencyResolver
+{
+ private readonly ITemplateEngineRepository _templates;
+ private readonly IExternalSystemRepository _externalSystems;
+ private readonly INotificationRepository _notifications;
+ private readonly IInboundApiRepository _inboundApi;
+
+ public DependencyResolver(
+ ITemplateEngineRepository templates,
+ IExternalSystemRepository externalSystems,
+ INotificationRepository notifications,
+ IInboundApiRepository inboundApi)
+ {
+ _templates = templates ?? throw new ArgumentNullException(nameof(templates));
+ _externalSystems = externalSystems ?? throw new ArgumentNullException(nameof(externalSystems));
+ _notifications = notifications ?? throw new ArgumentNullException(nameof(notifications));
+ _inboundApi = inboundApi ?? throw new ArgumentNullException(nameof(inboundApi));
+ }
+
+ public async Task ResolveAsync(ExportSelection selection, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(selection);
+
+ // ---- Seed: fetch the directly-selected entities ----
+ var templates = new Dictionary();
+ foreach (var id in selection.TemplateIds.Distinct())
+ {
+ var t = await _templates.GetTemplateWithChildrenAsync(id, ct).ConfigureAwait(false);
+ if (t is not null) templates[t.Id] = t;
+ }
+
+ var sharedScripts = new Dictionary();
+ foreach (var id in selection.SharedScriptIds.Distinct())
+ {
+ var s = await _templates.GetSharedScriptByIdAsync(id, ct).ConfigureAwait(false);
+ if (s is not null) sharedScripts[s.Id] = s;
+ }
+
+ var externalSystems = new Dictionary();
+ foreach (var id in selection.ExternalSystemIds.Distinct())
+ {
+ var es = await _externalSystems.GetExternalSystemByIdAsync(id, ct).ConfigureAwait(false);
+ if (es is not null) externalSystems[es.Id] = es;
+ }
+
+ var dbConnections = new Dictionary();
+ foreach (var id in selection.DatabaseConnectionIds.Distinct())
+ {
+ var db = await _externalSystems.GetDatabaseConnectionByIdAsync(id, ct).ConfigureAwait(false);
+ if (db is not null) dbConnections[db.Id] = db;
+ }
+
+ var notificationLists = new Dictionary();
+ foreach (var id in selection.NotificationListIds.Distinct())
+ {
+ var nl = await _notifications.GetNotificationListByIdAsync(id, ct).ConfigureAwait(false);
+ if (nl is not null) notificationLists[nl.Id] = nl;
+ }
+
+ var smtpConfigs = new Dictionary();
+ foreach (var id in selection.SmtpConfigurationIds.Distinct())
+ {
+ var sm = await _notifications.GetSmtpConfigurationByIdAsync(id, ct).ConfigureAwait(false);
+ if (sm is not null) smtpConfigs[sm.Id] = sm;
+ }
+
+ var apiKeys = new Dictionary();
+ foreach (var id in selection.ApiKeyIds.Distinct())
+ {
+ var k = await _inboundApi.GetApiKeyByIdAsync(id, ct).ConfigureAwait(false);
+ if (k is not null) apiKeys[k.Id] = k;
+ }
+
+ var apiMethods = new Dictionary();
+ foreach (var id in selection.ApiMethodIds.Distinct())
+ {
+ var m = await _inboundApi.GetApiMethodByIdAsync(id, ct).ConfigureAwait(false);
+ if (m is not null) apiMethods[m.Id] = m;
+ }
+
+ // ---- Dependency expansion ----
+ if (selection.IncludeDependencies)
+ {
+ await ExpandTemplateClosureAsync(templates, ct).ConfigureAwait(false);
+ await ExpandSharedScriptsFromTemplatesAsync(templates, sharedScripts, ct).ConfigureAwait(false);
+ await ExpandSharedScriptsFromApiMethodsAsync(apiMethods, sharedScripts, ct).ConfigureAwait(false);
+ await ExpandExternalSystemsFromTemplatesAsync(templates, externalSystems, ct).ConfigureAwait(false);
+ }
+
+ // ExternalSystemMethods always travel with their parent ExternalSystem.
+ var externalSystemMethods = new List();
+ foreach (var es in externalSystems.Values.OrderBy(x => x.Name, StringComparer.Ordinal))
+ {
+ var methods = await _externalSystems
+ .GetMethodsByExternalSystemIdAsync(es.Id, ct)
+ .ConfigureAwait(false);
+ externalSystemMethods.AddRange(methods);
+ }
+
+ // Folder ancestor chain — always pull, regardless of IncludeDependencies,
+ // so the imported tree never references missing parents.
+ var folders = await ResolveFolderChainAsync(templates.Values, ct).ConfigureAwait(false);
+
+ // ---- Topological sort of templates (base-before-derived) ----
+ var orderedTemplates = TopologicallySortTemplates(templates.Values);
+
+ // ---- Build deterministic content manifest ----
+ var manifest = BuildContentManifest(
+ folders,
+ orderedTemplates,
+ sharedScripts.Values,
+ externalSystems.Values,
+ externalSystemMethods,
+ dbConnections.Values,
+ notificationLists.Values,
+ smtpConfigs.Values,
+ apiKeys.Values,
+ apiMethods.Values);
+
+ return new ResolvedExport(
+ TemplateFolders: folders,
+ Templates: orderedTemplates,
+ SharedScripts: sharedScripts.Values.OrderBy(s => s.Name, StringComparer.Ordinal).ToList(),
+ ExternalSystems: externalSystems.Values.OrderBy(e => e.Name, StringComparer.Ordinal).ToList(),
+ ExternalSystemMethods: externalSystemMethods,
+ DatabaseConnections: dbConnections.Values.OrderBy(d => d.Name, StringComparer.Ordinal).ToList(),
+ NotificationLists: notificationLists.Values.OrderBy(n => n.Name, StringComparer.Ordinal).ToList(),
+ SmtpConfigs: smtpConfigs.Values.OrderBy(s => s.Host, StringComparer.Ordinal).ToList(),
+ ApiKeys: apiKeys.Values.OrderBy(a => a.Name, StringComparer.Ordinal).ToList(),
+ ApiMethods: apiMethods.Values.OrderBy(a => a.Name, StringComparer.Ordinal).ToList(),
+ ContentManifest: manifest);
+ }
+
+ // ---- Template composition closure ----
+ private async Task ExpandTemplateClosureAsync(Dictionary templates, CancellationToken ct)
+ {
+ var queue = new Queue(templates.Values);
+ while (queue.Count > 0)
+ {
+ var current = queue.Dequeue();
+ foreach (var comp in current.Compositions)
+ {
+ if (templates.ContainsKey(comp.ComposedTemplateId)) continue;
+ var composed = await _templates
+ .GetTemplateWithChildrenAsync(comp.ComposedTemplateId, ct)
+ .ConfigureAwait(false);
+ if (composed is null) continue;
+ templates[composed.Id] = composed;
+ queue.Enqueue(composed);
+ }
+ }
+ }
+
+ // ---- SharedScript pulls from template script bodies + attributes ----
+ private async Task ExpandSharedScriptsFromTemplatesAsync(
+ Dictionary templates,
+ Dictionary sharedScripts,
+ CancellationToken ct)
+ {
+ var allShared = await _templates.GetAllSharedScriptsAsync(ct).ConfigureAwait(false);
+ foreach (var shared in allShared)
+ {
+ if (sharedScripts.ContainsKey(shared.Id)) continue;
+ if (TemplatesReferenceName(templates.Values, shared.Name))
+ {
+ sharedScripts[shared.Id] = shared;
+ }
+ }
+ }
+
+ // ---- SharedScript pulls from ApiMethod.Script bodies ----
+ private async Task ExpandSharedScriptsFromApiMethodsAsync(
+ Dictionary apiMethods,
+ Dictionary sharedScripts,
+ CancellationToken ct)
+ {
+ if (apiMethods.Count == 0) return;
+ var allShared = await _templates.GetAllSharedScriptsAsync(ct).ConfigureAwait(false);
+ foreach (var shared in allShared)
+ {
+ if (sharedScripts.ContainsKey(shared.Id)) continue;
+ if (apiMethods.Values.Any(m => ContainsIdentifier(m.Script, shared.Name)))
+ {
+ sharedScripts[shared.Id] = shared;
+ }
+ }
+ }
+
+ // ---- ExternalSystem pulls from template script/attribute bodies ----
+ private async Task ExpandExternalSystemsFromTemplatesAsync(
+ Dictionary templates,
+ Dictionary externalSystems,
+ CancellationToken ct)
+ {
+ var allSystems = await _externalSystems.GetAllExternalSystemsAsync(ct).ConfigureAwait(false);
+ foreach (var es in allSystems)
+ {
+ if (externalSystems.ContainsKey(es.Id)) continue;
+ if (TemplatesReferenceName(templates.Values, es.Name))
+ {
+ externalSystems[es.Id] = es;
+ }
+ }
+ }
+
+ private static bool TemplatesReferenceName(IEnumerable templates, string name)
+ {
+ foreach (var t in templates)
+ {
+ foreach (var s in t.Scripts)
+ {
+ if (ContainsIdentifier(s.Code, name)) return true;
+ }
+ foreach (var a in t.Attributes)
+ {
+ if (a.DataSourceReference is not null && ContainsIdentifier(a.DataSourceReference, name)) return true;
+ if (a.Value is not null && ContainsIdentifier(a.Value, name)) return true;
+ }
+ }
+ return false;
+ }
+
+ ///
+ /// Substring scan with word-boundary guarding: the name must not be a partial
+ /// match of a longer identifier. Cheap, dependency-free, sufficient for the
+ /// v1 export-time scan (callers can always over-include with explicit ids).
+ ///
+ private static bool ContainsIdentifier(string? haystack, string name)
+ {
+ if (string.IsNullOrEmpty(haystack) || string.IsNullOrEmpty(name)) return false;
+ var idx = 0;
+ while (true)
+ {
+ var hit = haystack.IndexOf(name, idx, StringComparison.Ordinal);
+ if (hit < 0) return false;
+ var before = hit == 0 ? '\0' : haystack[hit - 1];
+ var afterIdx = hit + name.Length;
+ var after = afterIdx >= haystack.Length ? '\0' : haystack[afterIdx];
+ if (!IsIdentifierChar(before) && !IsIdentifierChar(after)) return true;
+ idx = hit + 1;
+ }
+ }
+
+ private static bool IsIdentifierChar(char c) => c == '_' || char.IsLetterOrDigit(c);
+
+ // ---- Folder ancestor chain ----
+ private async Task> ResolveFolderChainAsync(
+ IEnumerable templates,
+ CancellationToken ct)
+ {
+ var allFolders = await _templates.GetAllFoldersAsync(ct).ConfigureAwait(false);
+ var byId = allFolders.ToDictionary(f => f.Id);
+
+ var needed = new Dictionary();
+ foreach (var t in templates)
+ {
+ var fid = t.FolderId;
+ while (fid is not null && byId.TryGetValue(fid.Value, out var folder))
+ {
+ if (!needed.TryAdd(folder.Id, folder)) break; // already walked this chain
+ fid = folder.ParentFolderId;
+ }
+ }
+
+ // Root-first ordering so importers create parents before children.
+ return needed.Values
+ .OrderBy(f => DepthOf(f, byId))
+ .ThenBy(f => f.SortOrder)
+ .ThenBy(f => f.Name, StringComparer.Ordinal)
+ .ToList();
+ }
+
+ private static int DepthOf(TemplateFolder folder, IReadOnlyDictionary byId)
+ {
+ var depth = 0;
+ var current = folder;
+ while (current.ParentFolderId is int pid && byId.TryGetValue(pid, out var parent))
+ {
+ depth++;
+ current = parent;
+ if (depth > 1024) break; // defensive: folder graph is acyclic by schema
+ }
+ return depth;
+ }
+
+ // ---- Kahn's algorithm: order templates base-before-derived ----
+ internal static List TopologicallySortTemplates(IEnumerable templates)
+ {
+ // Edge: composed --> composing (composed must appear first).
+ var nodes = templates.ToDictionary(t => t.Id);
+ var inDegree = nodes.ToDictionary(kv => kv.Key, _ => 0);
+ var outEdges = nodes.ToDictionary(kv => kv.Key, _ => new List());
+
+ foreach (var t in nodes.Values)
+ {
+ foreach (var comp in t.Compositions)
+ {
+ if (!nodes.ContainsKey(comp.ComposedTemplateId)) continue;
+ outEdges[comp.ComposedTemplateId].Add(t.Id);
+ inDegree[t.Id]++;
+ }
+ }
+
+ var queue = new Queue(
+ inDegree.Where(kv => kv.Value == 0)
+ .OrderBy(kv => nodes[kv.Key].Name, StringComparer.Ordinal)
+ .Select(kv => kv.Key));
+
+ var result = new List(nodes.Count);
+ while (queue.Count > 0)
+ {
+ var id = queue.Dequeue();
+ result.Add(nodes[id]);
+ foreach (var next in outEdges[id].OrderBy(x => nodes[x].Name, StringComparer.Ordinal))
+ {
+ if (--inDegree[next] == 0) queue.Enqueue(next);
+ }
+ }
+
+ if (result.Count != nodes.Count)
+ throw new InvalidOperationException("Template composition graph is cyclic");
+ return result;
+ }
+
+ private static IReadOnlyList BuildContentManifest(
+ IReadOnlyList folders,
+ IReadOnlyList templates,
+ IEnumerable sharedScripts,
+ IEnumerable externalSystems,
+ IEnumerable externalSystemMethods,
+ IEnumerable dbConnections,
+ IEnumerable notificationLists,
+ IEnumerable smtpConfigs,
+ IEnumerable apiKeys,
+ IEnumerable apiMethods)
+ {
+ var entries = new List();
+
+ foreach (var f in folders)
+ {
+ entries.Add(new ManifestContentEntry("TemplateFolder", f.Name, 1, Array.Empty()));
+ }
+ foreach (var t in templates)
+ {
+ var deps = t.Compositions
+ .Select(c => $"Template:{(templates.FirstOrDefault(x => x.Id == c.ComposedTemplateId)?.Name ?? c.ComposedTemplateId.ToString())}")
+ .OrderBy(x => x, StringComparer.Ordinal)
+ .ToList();
+ entries.Add(new ManifestContentEntry("Template", t.Name, 1, deps));
+ }
+ foreach (var s in sharedScripts.OrderBy(x => x.Name, StringComparer.Ordinal))
+ {
+ entries.Add(new ManifestContentEntry("SharedScript", s.Name, 1, Array.Empty()));
+ }
+ foreach (var es in externalSystems.OrderBy(x => x.Name, StringComparer.Ordinal))
+ {
+ entries.Add(new ManifestContentEntry("ExternalSystem", es.Name, 1, Array.Empty()));
+ }
+ var methodsBySystem = externalSystemMethods.GroupBy(m => m.ExternalSystemDefinitionId);
+ var systemById = externalSystems.ToDictionary(x => x.Id);
+ foreach (var grp in methodsBySystem.OrderBy(g => systemById.TryGetValue(g.Key, out var es) ? es.Name : string.Empty, StringComparer.Ordinal))
+ {
+ var systemName = systemById.TryGetValue(grp.Key, out var es) ? es.Name : grp.Key.ToString();
+ foreach (var m in grp.OrderBy(x => x.Name, StringComparer.Ordinal))
+ {
+ entries.Add(new ManifestContentEntry(
+ "ExternalSystemMethod",
+ $"{systemName}.{m.Name}",
+ 1,
+ new[] { $"ExternalSystem:{systemName}" }));
+ }
+ }
+ foreach (var d in dbConnections.OrderBy(x => x.Name, StringComparer.Ordinal))
+ {
+ entries.Add(new ManifestContentEntry("DatabaseConnection", d.Name, 1, Array.Empty()));
+ }
+ foreach (var n in notificationLists.OrderBy(x => x.Name, StringComparer.Ordinal))
+ {
+ entries.Add(new ManifestContentEntry("NotificationList", n.Name, 1, Array.Empty()));
+ }
+ foreach (var s in smtpConfigs.OrderBy(x => x.Host, StringComparer.Ordinal))
+ {
+ entries.Add(new ManifestContentEntry("SmtpConfiguration", s.Host, 1, Array.Empty()));
+ }
+ foreach (var k in apiKeys.OrderBy(x => x.Name, StringComparer.Ordinal))
+ {
+ entries.Add(new ManifestContentEntry("ApiKey", k.Name, 1, Array.Empty()));
+ }
+ foreach (var m in apiMethods.OrderBy(x => x.Name, StringComparer.Ordinal))
+ {
+ entries.Add(new ManifestContentEntry("ApiMethod", m.Name, 1, Array.Empty()));
+ }
+
+ return entries;
+ }
+}
diff --git a/src/ScadaLink.Transport/Export/ResolvedExport.cs b/src/ScadaLink.Transport/Export/ResolvedExport.cs
new file mode 100644
index 0000000..f0834f2
--- /dev/null
+++ b/src/ScadaLink.Transport/Export/ResolvedExport.cs
@@ -0,0 +1,28 @@
+using ScadaLink.Commons.Entities.ExternalSystems;
+using ScadaLink.Commons.Entities.InboundApi;
+using ScadaLink.Commons.Entities.Notifications;
+using ScadaLink.Commons.Entities.Scripts;
+using ScadaLink.Commons.Entities.Templates;
+using ScadaLink.Commons.Types.Transport;
+
+namespace ScadaLink.Transport.Export;
+
+///
+/// Output of — the full closure of
+/// entities that need to land in a bundle for the given ,
+/// along with a stable, manifest-ready ContentManifest. Templates are
+/// topologically ordered (base before derived) so the importer can apply them
+/// in-order without further sorting.
+///
+public sealed record ResolvedExport(
+ IReadOnlyList TemplateFolders,
+ IReadOnlyList Templates,
+ IReadOnlyList SharedScripts,
+ IReadOnlyList ExternalSystems,
+ IReadOnlyList ExternalSystemMethods,
+ IReadOnlyList DatabaseConnections,
+ IReadOnlyList NotificationLists,
+ IReadOnlyList SmtpConfigs,
+ IReadOnlyList ApiKeys,
+ IReadOnlyList ApiMethods,
+ IReadOnlyList ContentManifest);
diff --git a/src/ScadaLink.Transport/ServiceCollectionExtensions.cs b/src/ScadaLink.Transport/ServiceCollectionExtensions.cs
index ba819c3..99700aa 100644
--- a/src/ScadaLink.Transport/ServiceCollectionExtensions.cs
+++ b/src/ScadaLink.Transport/ServiceCollectionExtensions.cs
@@ -1,5 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
+using ScadaLink.Transport.Export;
namespace ScadaLink.Transport;
@@ -11,7 +12,8 @@ public static class ServiceCollectionExtensions
{
ArgumentNullException.ThrowIfNull(services);
services.AddOptions().BindConfiguration(OptionsSection);
- // Concrete services added in later tasks.
+ services.AddScoped();
+ // Remaining concrete services added in later tasks.
return services;
}
}
diff --git a/tests/ScadaLink.Transport.Tests/Export/DependencyResolverTests.cs b/tests/ScadaLink.Transport.Tests/Export/DependencyResolverTests.cs
new file mode 100644
index 0000000..d8ec857
--- /dev/null
+++ b/tests/ScadaLink.Transport.Tests/Export/DependencyResolverTests.cs
@@ -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();
+ private readonly IExternalSystemRepository _externalSystems = Substitute.For();
+ private readonly INotificationRepository _notifications = Substitute.For();
+ private readonly IInboundApiRepository _inboundApi = Substitute.For();
+
+ private DependencyResolver Sut() => new(_templates, _externalSystems, _notifications, _inboundApi);
+
+ private static ExportSelection SelectTemplates(params int[] ids) => new(
+ TemplateIds: ids,
+ SharedScriptIds: Array.Empty(),
+ ExternalSystemIds: Array.Empty(),
+ DatabaseConnectionIds: Array.Empty(),
+ NotificationListIds: Array.Empty(),
+ SmtpConfigurationIds: Array.Empty(),
+ ApiKeyIds: Array.Empty(),
+ ApiMethodIds: Array.Empty(),
+ IncludeDependencies: true);
+
+ private static ExportSelection SelectApiMethods(params int[] ids) => new(
+ TemplateIds: Array.Empty(),
+ SharedScriptIds: Array.Empty(),
+ ExternalSystemIds: Array.Empty(),
+ DatabaseConnectionIds: Array.Empty(),
+ NotificationListIds: Array.Empty(),
+ SmtpConfigurationIds: Array.Empty(),
+ ApiKeyIds: Array.Empty(),
+ ApiMethodIds: ids,
+ IncludeDependencies: true);
+
+ private void StubTemplate(Template t)
+ {
+ _templates.GetTemplateWithChildrenAsync(t.Id, Arg.Any()).Returns(t);
+ }
+
+ private void StubAllSharedScripts(params SharedScript[] scripts)
+ {
+ _templates.GetAllSharedScriptsAsync(Arg.Any()).Returns(scripts);
+ }
+
+ private void StubAllExternalSystems(params ExternalSystemDefinition[] systems)
+ {
+ _externalSystems.GetAllExternalSystemsAsync(Arg.Any()).Returns(systems);
+ foreach (var es in systems)
+ {
+ _externalSystems
+ .GetMethodsByExternalSystemIdAsync(es.Id, Arg.Any())
+ .Returns(Array.Empty());
+ }
+ }
+
+ private void StubAllFolders(params TemplateFolder[] folders)
+ {
+ _templates.GetAllFoldersAsync(Arg.Any()).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()).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);
+ }
+}