diff --git a/src/ScadaLink.Transport/Serialization/EntityDtos.cs b/src/ScadaLink.Transport/Serialization/EntityDtos.cs
new file mode 100644
index 0000000..864c1ee
--- /dev/null
+++ b/src/ScadaLink.Transport/Serialization/EntityDtos.cs
@@ -0,0 +1,155 @@
+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.Enums;
+
+namespace ScadaLink.Transport.Serialization;
+
+///
+/// In-memory aggregate of all bundle-eligible Commons entities. Matches the
+/// shape of but uses the real persistence-
+/// ignorant POCO types — what consumes/produces
+/// on the application side of the bundle boundary.
+///
+public sealed record EntityAggregate(
+ IReadOnlyList TemplateFolders,
+ IReadOnlyList Templates,
+ IReadOnlyList SharedScripts,
+ IReadOnlyList ExternalSystems,
+ IReadOnlyList ExternalSystemMethods,
+ IReadOnlyList DatabaseConnections,
+ IReadOnlyList NotificationLists,
+ IReadOnlyList SmtpConfigurations,
+ IReadOnlyList ApiKeys,
+ IReadOnlyList ApiMethods);
+
+///
+/// Top-level serializable bundle payload. Lists are sequenced in dependency
+/// order so importers can apply them inline. Lists are never null on the wire
+/// — empty arrays are preferred over nulls so JSON consumers can rely on each
+/// property being present.
+///
+public sealed record BundleContentDto(
+ IReadOnlyList TemplateFolders,
+ IReadOnlyList Templates,
+ IReadOnlyList SharedScripts,
+ IReadOnlyList ExternalSystems,
+ IReadOnlyList DatabaseConnections,
+ IReadOnlyList NotificationLists,
+ IReadOnlyList SmtpConfigs,
+ IReadOnlyList ApiKeys,
+ IReadOnlyList ApiMethods);
+
+///
+/// Carved-off secret values for an entity. The outer DTO carries all non-
+/// sensitive fields; secrets land here so a future "share without secrets"
+/// export mode can drop this block without touching anything else.
+///
+public sealed record SecretsBlock(IReadOnlyDictionary Values);
+
+public sealed record TemplateFolderDto(
+ string Name,
+ string? ParentName,
+ int SortOrder);
+
+public sealed record TemplateDto(
+ string Name,
+ string? FolderName,
+ string? BaseTemplateName,
+ string? Description,
+ IReadOnlyList Attributes,
+ IReadOnlyList Alarms,
+ IReadOnlyList Scripts,
+ IReadOnlyList Compositions);
+
+public sealed record TemplateAttributeDto(
+ string Name,
+ string? Value,
+ DataType DataType,
+ bool IsLocked,
+ string? Description,
+ string? DataSourceReference);
+
+public sealed record TemplateAlarmDto(
+ string Name,
+ string? Description,
+ int PriorityLevel,
+ AlarmTriggerType TriggerType,
+ string? TriggerConfiguration,
+ bool IsLocked);
+
+public sealed record TemplateScriptDto(
+ string Name,
+ string Code,
+ string? TriggerType,
+ string? TriggerConfiguration,
+ string? ParameterDefinitions,
+ string? ReturnDefinition,
+ bool IsLocked);
+
+public sealed record TemplateCompositionDto(
+ string InstanceName,
+ string ComposedTemplateName);
+
+public sealed record SharedScriptDto(
+ string Name,
+ string Code,
+ string? ParameterDefinitions,
+ string? ReturnDefinition);
+
+public sealed record ExternalSystemDto(
+ string Name,
+ string BaseUrl,
+ string AuthType,
+ IReadOnlyList Methods,
+ SecretsBlock? Secrets);
+
+public sealed record ExternalSystemMethodDto(
+ string Name,
+ string HttpMethod,
+ string Path,
+ string? ParameterDefinitions,
+ string? ReturnDefinition);
+
+public sealed record DatabaseConnectionDto(
+ string Name,
+ int MaxRetries,
+ TimeSpan RetryDelay,
+ SecretsBlock? Secrets);
+
+public sealed record NotificationListDto(
+ string Name,
+ NotificationType Type,
+ IReadOnlyList Recipients);
+
+public sealed record NotificationRecipientDto(
+ string Name,
+ string EmailAddress);
+
+public sealed record SmtpConfigDto(
+ string Host,
+ int Port,
+ string AuthType,
+ string FromAddress,
+ string? TlsMode,
+ int ConnectionTimeoutSeconds,
+ int MaxConcurrentConnections,
+ int MaxRetries,
+ TimeSpan RetryDelay,
+ SecretsBlock? Secrets);
+
+public sealed record ApiKeyDto(
+ string Name,
+ string KeyHash,
+ bool IsEnabled,
+ SecretsBlock? Secrets);
+
+public sealed record ApiMethodDto(
+ string Name,
+ string Script,
+ string? ApprovedApiKeyIds,
+ string? ParameterDefinitions,
+ string? ReturnDefinition,
+ int TimeoutSeconds);
diff --git a/src/ScadaLink.Transport/Serialization/EntitySerializer.cs b/src/ScadaLink.Transport/Serialization/EntitySerializer.cs
new file mode 100644
index 0000000..7e856de
--- /dev/null
+++ b/src/ScadaLink.Transport/Serialization/EntitySerializer.cs
@@ -0,0 +1,344 @@
+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;
+
+namespace ScadaLink.Transport.Serialization;
+
+///
+/// Converts between the persistence-shaped and
+/// the wire-shaped . The conversion strips EF
+/// identity (FK ints) and references inter-entity links by name so bundles
+/// remain portable across environments. Secrets are carved into a per-entity
+/// so the bundle's secret-free public surface can
+/// be inspected (or, in future, exported without secrets) without touching the
+/// rest of the payload.
+///
+public sealed class EntitySerializer
+{
+ public BundleContentDto ToBundleContent(EntityAggregate aggregate)
+ {
+ ArgumentNullException.ThrowIfNull(aggregate);
+
+ var folderNameById = aggregate.TemplateFolders.ToDictionary(f => f.Id, f => f.Name);
+ var templateNameById = aggregate.Templates.ToDictionary(t => t.Id, t => t.Name);
+
+ return new BundleContentDto(
+ TemplateFolders: aggregate.TemplateFolders.Select(f => new TemplateFolderDto(
+ Name: f.Name,
+ ParentName: f.ParentFolderId is { } pid && folderNameById.TryGetValue(pid, out var pname) ? pname : null,
+ SortOrder: f.SortOrder)).ToList(),
+ Templates: aggregate.Templates.Select(t => new TemplateDto(
+ Name: t.Name,
+ FolderName: t.FolderId is { } fid && folderNameById.TryGetValue(fid, out var fname) ? fname : null,
+ BaseTemplateName: t.ParentTemplateId is { } btid && templateNameById.TryGetValue(btid, out var bname) ? bname : null,
+ Description: t.Description,
+ Attributes: t.Attributes.Select(a => new TemplateAttributeDto(
+ Name: a.Name,
+ Value: a.Value,
+ DataType: a.DataType,
+ IsLocked: a.IsLocked,
+ Description: a.Description,
+ DataSourceReference: a.DataSourceReference)).ToList(),
+ Alarms: t.Alarms.Select(a => new TemplateAlarmDto(
+ Name: a.Name,
+ Description: a.Description,
+ PriorityLevel: a.PriorityLevel,
+ TriggerType: a.TriggerType,
+ TriggerConfiguration: a.TriggerConfiguration,
+ IsLocked: a.IsLocked)).ToList(),
+ Scripts: t.Scripts.Select(s => new TemplateScriptDto(
+ Name: s.Name,
+ Code: s.Code,
+ TriggerType: s.TriggerType,
+ TriggerConfiguration: s.TriggerConfiguration,
+ ParameterDefinitions: s.ParameterDefinitions,
+ ReturnDefinition: s.ReturnDefinition,
+ IsLocked: s.IsLocked)).ToList(),
+ Compositions: t.Compositions.Select(c => new TemplateCompositionDto(
+ InstanceName: c.InstanceName,
+ ComposedTemplateName: templateNameById.TryGetValue(c.ComposedTemplateId, out var cn) ? cn : string.Empty)).ToList())).ToList(),
+ SharedScripts: aggregate.SharedScripts.Select(s => new SharedScriptDto(
+ Name: s.Name,
+ Code: s.Code,
+ ParameterDefinitions: s.ParameterDefinitions,
+ ReturnDefinition: s.ReturnDefinition)).ToList(),
+ ExternalSystems: aggregate.ExternalSystems.Select(sys =>
+ {
+ var methods = aggregate.ExternalSystemMethods
+ .Where(m => m.ExternalSystemDefinitionId == sys.Id)
+ .Select(m => new ExternalSystemMethodDto(
+ Name: m.Name,
+ HttpMethod: m.HttpMethod,
+ Path: m.Path,
+ ParameterDefinitions: m.ParameterDefinitions,
+ ReturnDefinition: m.ReturnDefinition))
+ .ToList();
+
+ SecretsBlock? secrets = null;
+ if (!string.IsNullOrEmpty(sys.AuthConfiguration))
+ {
+ secrets = new SecretsBlock(new Dictionary(StringComparer.Ordinal)
+ {
+ ["AuthConfiguration"] = sys.AuthConfiguration,
+ });
+ }
+
+ return new ExternalSystemDto(
+ Name: sys.Name,
+ BaseUrl: sys.EndpointUrl,
+ AuthType: sys.AuthType,
+ Methods: methods,
+ Secrets: secrets);
+ }).ToList(),
+ DatabaseConnections: aggregate.DatabaseConnections.Select(dc => new DatabaseConnectionDto(
+ Name: dc.Name,
+ MaxRetries: dc.MaxRetries,
+ RetryDelay: dc.RetryDelay,
+ // Connection strings typically embed credentials inline; treat the entire
+ // string as secret rather than try to parse out the password fragment.
+ Secrets: new SecretsBlock(new Dictionary(StringComparer.Ordinal)
+ {
+ ["ConnectionString"] = dc.ConnectionString,
+ }))).ToList(),
+ NotificationLists: aggregate.NotificationLists.Select(nl => new NotificationListDto(
+ Name: nl.Name,
+ Type: nl.Type,
+ Recipients: nl.Recipients.Select(r => new NotificationRecipientDto(
+ Name: r.Name,
+ EmailAddress: r.EmailAddress)).ToList())).ToList(),
+ SmtpConfigs: aggregate.SmtpConfigurations.Select(smtp =>
+ {
+ SecretsBlock? secrets = null;
+ if (!string.IsNullOrEmpty(smtp.Credentials))
+ {
+ secrets = new SecretsBlock(new Dictionary(StringComparer.Ordinal)
+ {
+ ["Credentials"] = smtp.Credentials,
+ });
+ }
+
+ return new SmtpConfigDto(
+ Host: smtp.Host,
+ Port: smtp.Port,
+ AuthType: smtp.AuthType,
+ FromAddress: smtp.FromAddress,
+ TlsMode: smtp.TlsMode,
+ ConnectionTimeoutSeconds: smtp.ConnectionTimeoutSeconds,
+ MaxConcurrentConnections: smtp.MaxConcurrentConnections,
+ MaxRetries: smtp.MaxRetries,
+ RetryDelay: smtp.RetryDelay,
+ Secrets: secrets);
+ }).ToList(),
+ // ApiKey stores only KeyHash already; no plaintext to carve. SecretsBlock
+ // stays null per design — KeyHash is on the public DTO.
+ ApiKeys: aggregate.ApiKeys.Select(k => new ApiKeyDto(
+ Name: k.Name,
+ KeyHash: k.KeyHash,
+ IsEnabled: k.IsEnabled,
+ Secrets: null)).ToList(),
+ ApiMethods: aggregate.ApiMethods.Select(m => new ApiMethodDto(
+ Name: m.Name,
+ Script: m.Script,
+ ApprovedApiKeyIds: m.ApprovedApiKeyIds,
+ ParameterDefinitions: m.ParameterDefinitions,
+ ReturnDefinition: m.ReturnDefinition,
+ TimeoutSeconds: m.TimeoutSeconds)).ToList());
+ }
+
+ public EntityAggregate FromBundleContent(BundleContentDto content)
+ {
+ ArgumentNullException.ThrowIfNull(content);
+
+ // Folders: assign synthetic ids by ordinal, resolve parent pointers by name.
+ var folders = content.TemplateFolders
+ .Select((dto, ix) => new TemplateFolder(dto.Name) { Id = ix + 1, SortOrder = dto.SortOrder })
+ .ToList();
+ var folderIdByName = folders.ToDictionary(f => f.Name, f => f.Id, StringComparer.Ordinal);
+ for (var i = 0; i < folders.Count; i++)
+ {
+ var parentName = content.TemplateFolders[i].ParentName;
+ if (parentName is not null && folderIdByName.TryGetValue(parentName, out var parentId))
+ {
+ folders[i].ParentFolderId = parentId;
+ }
+ }
+
+ // Templates: same pattern — assign ids, second pass resolves base + folder.
+ var templates = content.Templates
+ .Select((dto, ix) =>
+ {
+ var t = new Template(dto.Name) { Id = ix + 1, Description = dto.Description };
+ foreach (var a in dto.Attributes)
+ {
+ t.Attributes.Add(new TemplateAttribute(a.Name)
+ {
+ TemplateId = t.Id,
+ Value = a.Value,
+ DataType = a.DataType,
+ IsLocked = a.IsLocked,
+ Description = a.Description,
+ DataSourceReference = a.DataSourceReference,
+ });
+ }
+ foreach (var al in dto.Alarms)
+ {
+ t.Alarms.Add(new TemplateAlarm(al.Name)
+ {
+ TemplateId = t.Id,
+ Description = al.Description,
+ PriorityLevel = al.PriorityLevel,
+ TriggerType = al.TriggerType,
+ TriggerConfiguration = al.TriggerConfiguration,
+ IsLocked = al.IsLocked,
+ });
+ }
+ foreach (var s in dto.Scripts)
+ {
+ t.Scripts.Add(new TemplateScript(s.Name, s.Code)
+ {
+ TemplateId = t.Id,
+ TriggerType = s.TriggerType,
+ TriggerConfiguration = s.TriggerConfiguration,
+ ParameterDefinitions = s.ParameterDefinitions,
+ ReturnDefinition = s.ReturnDefinition,
+ IsLocked = s.IsLocked,
+ });
+ }
+ return t;
+ })
+ .ToList();
+ var templateIdByName = templates.ToDictionary(t => t.Name, t => t.Id, StringComparer.Ordinal);
+ for (var i = 0; i < templates.Count; i++)
+ {
+ var dto = content.Templates[i];
+ if (dto.FolderName is not null && folderIdByName.TryGetValue(dto.FolderName, out var fid))
+ {
+ templates[i].FolderId = fid;
+ }
+ if (dto.BaseTemplateName is not null && templateIdByName.TryGetValue(dto.BaseTemplateName, out var btid))
+ {
+ templates[i].ParentTemplateId = btid;
+ }
+ foreach (var compDto in dto.Compositions)
+ {
+ var comp = new TemplateComposition(compDto.InstanceName)
+ {
+ TemplateId = templates[i].Id,
+ };
+ if (templateIdByName.TryGetValue(compDto.ComposedTemplateName, out var ctid))
+ {
+ comp.ComposedTemplateId = ctid;
+ }
+ templates[i].Compositions.Add(comp);
+ }
+ }
+
+ var sharedScripts = content.SharedScripts
+ .Select((dto, ix) => new SharedScript(dto.Name, dto.Code)
+ {
+ Id = ix + 1,
+ ParameterDefinitions = dto.ParameterDefinitions,
+ ReturnDefinition = dto.ReturnDefinition,
+ })
+ .ToList();
+
+ var externalSystems = new List();
+ var externalSystemMethods = new List();
+ for (var ix = 0; ix < content.ExternalSystems.Count; ix++)
+ {
+ var dto = content.ExternalSystems[ix];
+ var sys = new ExternalSystemDefinition(dto.Name, dto.BaseUrl, dto.AuthType)
+ {
+ Id = ix + 1,
+ AuthConfiguration = dto.Secrets?.Values.TryGetValue("AuthConfiguration", out var auth) == true ? auth : null,
+ };
+ externalSystems.Add(sys);
+ foreach (var m in dto.Methods)
+ {
+ externalSystemMethods.Add(new ExternalSystemMethod(m.Name, m.HttpMethod, m.Path)
+ {
+ ExternalSystemDefinitionId = sys.Id,
+ ParameterDefinitions = m.ParameterDefinitions,
+ ReturnDefinition = m.ReturnDefinition,
+ });
+ }
+ }
+
+ var databaseConnections = content.DatabaseConnections
+ .Select((dto, ix) =>
+ {
+ var connStr = dto.Secrets?.Values.TryGetValue("ConnectionString", out var cs) == true ? cs : string.Empty;
+ return new DatabaseConnectionDefinition(dto.Name, connStr)
+ {
+ Id = ix + 1,
+ MaxRetries = dto.MaxRetries,
+ RetryDelay = dto.RetryDelay,
+ };
+ })
+ .ToList();
+
+ var notificationLists = content.NotificationLists
+ .Select((dto, ix) =>
+ {
+ var list = new NotificationList(dto.Name) { Id = ix + 1, Type = dto.Type };
+ foreach (var r in dto.Recipients)
+ {
+ list.Recipients.Add(new NotificationRecipient(r.Name, r.EmailAddress)
+ {
+ NotificationListId = list.Id,
+ });
+ }
+ return list;
+ })
+ .ToList();
+
+ var smtpConfigurations = content.SmtpConfigs
+ .Select((dto, ix) => new SmtpConfiguration(dto.Host, dto.AuthType, dto.FromAddress)
+ {
+ Id = ix + 1,
+ Port = dto.Port,
+ Credentials = dto.Secrets?.Values.TryGetValue("Credentials", out var cred) == true ? cred : null,
+ TlsMode = dto.TlsMode,
+ ConnectionTimeoutSeconds = dto.ConnectionTimeoutSeconds,
+ MaxConcurrentConnections = dto.MaxConcurrentConnections,
+ MaxRetries = dto.MaxRetries,
+ RetryDelay = dto.RetryDelay,
+ })
+ .ToList();
+
+ var apiKeys = content.ApiKeys
+ .Select((dto, ix) =>
+ {
+ var key = ApiKey.FromHash(dto.Name, dto.KeyHash);
+ key.Id = ix + 1;
+ key.IsEnabled = dto.IsEnabled;
+ return key;
+ })
+ .ToList();
+
+ var apiMethods = content.ApiMethods
+ .Select((dto, ix) => new ApiMethod(dto.Name, dto.Script)
+ {
+ Id = ix + 1,
+ ApprovedApiKeyIds = dto.ApprovedApiKeyIds,
+ ParameterDefinitions = dto.ParameterDefinitions,
+ ReturnDefinition = dto.ReturnDefinition,
+ TimeoutSeconds = dto.TimeoutSeconds,
+ })
+ .ToList();
+
+ return new EntityAggregate(
+ TemplateFolders: folders,
+ Templates: templates,
+ SharedScripts: sharedScripts,
+ ExternalSystems: externalSystems,
+ ExternalSystemMethods: externalSystemMethods,
+ DatabaseConnections: databaseConnections,
+ NotificationLists: notificationLists,
+ SmtpConfigurations: smtpConfigurations,
+ ApiKeys: apiKeys,
+ ApiMethods: apiMethods);
+ }
+}
diff --git a/tests/ScadaLink.Transport.Tests/Serialization/EntitySerializerTests.cs b/tests/ScadaLink.Transport.Tests/Serialization/EntitySerializerTests.cs
new file mode 100644
index 0000000..65ba701
--- /dev/null
+++ b/tests/ScadaLink.Transport.Tests/Serialization/EntitySerializerTests.cs
@@ -0,0 +1,189 @@
+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.Enums;
+using ScadaLink.Transport.Serialization;
+
+namespace ScadaLink.Transport.Tests.Serialization;
+
+public sealed class EntitySerializerTests
+{
+ private static EntityAggregate MakeEmptyAggregate() => new(
+ TemplateFolders: Array.Empty(),
+ Templates: Array.Empty(),
+ SharedScripts: Array.Empty(),
+ ExternalSystems: Array.Empty(),
+ ExternalSystemMethods: Array.Empty(),
+ DatabaseConnections: Array.Empty(),
+ NotificationLists: Array.Empty(),
+ SmtpConfigurations: Array.Empty(),
+ ApiKeys: Array.Empty(),
+ ApiMethods: Array.Empty());
+
+ [Fact]
+ public void ToDto_carves_external_system_credentials_into_secrets_block()
+ {
+ var sys = new ExternalSystemDefinition("erp", "https://erp/api", "ApiKey")
+ {
+ Id = 1,
+ AuthConfiguration = "{\"apiKey\":\"super-secret\"}",
+ };
+ var aggregate = MakeEmptyAggregate() with { ExternalSystems = new[] { sys } };
+
+ var sut = new EntitySerializer();
+ var dto = sut.ToBundleContent(aggregate);
+
+ var dtoSys = Assert.Single(dto.ExternalSystems);
+ Assert.NotNull(dtoSys.Secrets);
+ Assert.True(dtoSys.Secrets!.Values.ContainsKey("AuthConfiguration"));
+ Assert.Equal("{\"apiKey\":\"super-secret\"}", dtoSys.Secrets.Values["AuthConfiguration"]);
+ // Public part does not carry the secret.
+ Assert.Equal("erp", dtoSys.Name);
+ Assert.Equal("https://erp/api", dtoSys.BaseUrl);
+ Assert.Equal("ApiKey", dtoSys.AuthType);
+ }
+
+ [Fact]
+ public void ToDto_carves_smtp_password_into_secrets_block()
+ {
+ var smtp = new SmtpConfiguration("smtp.example.com", "Basic", "noreply@example.com")
+ {
+ Id = 1,
+ Port = 587,
+ Credentials = "user:p@ssw0rd",
+ };
+ var aggregate = MakeEmptyAggregate() with { SmtpConfigurations = new[] { smtp } };
+
+ var dto = new EntitySerializer().ToBundleContent(aggregate);
+
+ var dtoSmtp = Assert.Single(dto.SmtpConfigs);
+ Assert.NotNull(dtoSmtp.Secrets);
+ Assert.Equal("user:p@ssw0rd", dtoSmtp.Secrets!.Values["Credentials"]);
+ Assert.Equal("smtp.example.com", dtoSmtp.Host);
+ Assert.Equal(587, dtoSmtp.Port);
+ }
+
+ [Fact]
+ public void Roundtrip_template_preserves_attributes_alarms_scripts_composition()
+ {
+ var folder = new TemplateFolder("root") { Id = 1, SortOrder = 0 };
+ var basic = new Template("Basic") { Id = 1, FolderId = 1, Description = "base" };
+ basic.Attributes.Add(new TemplateAttribute("Pressure")
+ {
+ Id = 1,
+ TemplateId = 1,
+ DataType = DataType.Double,
+ Value = "0",
+ IsLocked = true,
+ Description = "PSI",
+ });
+ basic.Alarms.Add(new TemplateAlarm("High")
+ {
+ Id = 1,
+ TemplateId = 1,
+ PriorityLevel = 2,
+ TriggerType = AlarmTriggerType.RangeViolation,
+ TriggerConfiguration = "{\"threshold\":100}",
+ IsLocked = false,
+ });
+ basic.Scripts.Add(new TemplateScript("OnUpdate", "return 1;")
+ {
+ Id = 1,
+ TemplateId = 1,
+ TriggerType = "Periodic",
+ ParameterDefinitions = "[]",
+ ReturnDefinition = "void",
+ IsLocked = false,
+ });
+
+ var assembly = new Template("Assembly") { Id = 2, FolderId = 1 };
+ assembly.Compositions.Add(new TemplateComposition("MotorA")
+ {
+ Id = 1,
+ TemplateId = 2,
+ ComposedTemplateId = 1, // refers to "Basic".
+ });
+
+ var aggregate = MakeEmptyAggregate() with
+ {
+ TemplateFolders = new[] { folder },
+ Templates = new[] { basic, assembly },
+ };
+
+ var sut = new EntitySerializer();
+ var dto = sut.ToBundleContent(aggregate);
+ var roundTripped = sut.FromBundleContent(dto);
+
+ var rtBasic = Assert.Single(roundTripped.Templates, t => t.Name == "Basic");
+ var rtAttr = Assert.Single(rtBasic.Attributes);
+ Assert.Equal("Pressure", rtAttr.Name);
+ Assert.Equal(DataType.Double, rtAttr.DataType);
+ Assert.Equal("0", rtAttr.Value);
+ Assert.True(rtAttr.IsLocked);
+
+ var rtAlarm = Assert.Single(rtBasic.Alarms);
+ Assert.Equal("High", rtAlarm.Name);
+ Assert.Equal(AlarmTriggerType.RangeViolation, rtAlarm.TriggerType);
+ Assert.Equal("{\"threshold\":100}", rtAlarm.TriggerConfiguration);
+ Assert.Equal(2, rtAlarm.PriorityLevel);
+
+ var rtScript = Assert.Single(rtBasic.Scripts);
+ Assert.Equal("OnUpdate", rtScript.Name);
+ Assert.Equal("return 1;", rtScript.Code);
+ Assert.Equal("Periodic", rtScript.TriggerType);
+
+ var rtAssembly = Assert.Single(roundTripped.Templates, t => t.Name == "Assembly");
+ var rtComp = Assert.Single(rtAssembly.Compositions);
+ Assert.Equal("MotorA", rtComp.InstanceName);
+ }
+
+ [Fact]
+ public void Roundtrip_template_folder_preserves_hierarchy()
+ {
+ var root = new TemplateFolder("Root") { Id = 1, SortOrder = 0 };
+ var child = new TemplateFolder("Pumps") { Id = 2, ParentFolderId = 1, SortOrder = 1 };
+
+ var aggregate = MakeEmptyAggregate() with
+ {
+ TemplateFolders = new[] { root, child },
+ };
+
+ var sut = new EntitySerializer();
+ var dto = sut.ToBundleContent(aggregate);
+ var rt = sut.FromBundleContent(dto);
+
+ Assert.Equal(2, rt.TemplateFolders.Count);
+ var rtRoot = Assert.Single(rt.TemplateFolders, f => f.Name == "Root");
+ var rtChild = Assert.Single(rt.TemplateFolders, f => f.Name == "Pumps");
+ Assert.Null(rtRoot.ParentFolderId);
+ // Hierarchy is preserved by name reference; new local ids get assigned but
+ // the child's parent must still point at the row whose name is "Root".
+ Assert.NotNull(rtChild.ParentFolderId);
+ Assert.Equal(rtRoot.Id, rtChild.ParentFolderId);
+ }
+
+ [Fact]
+ public void FromDto_with_null_SecretsBlock_yields_entity_with_default_empty_secrets()
+ {
+ var dto = new BundleContentDto(
+ TemplateFolders: Array.Empty(),
+ Templates: Array.Empty(),
+ SharedScripts: Array.Empty(),
+ ExternalSystems: new[]
+ {
+ new ExternalSystemDto("erp", "https://x", "None", Array.Empty(), Secrets: null),
+ },
+ DatabaseConnections: Array.Empty(),
+ NotificationLists: Array.Empty(),
+ SmtpConfigs: Array.Empty(),
+ ApiKeys: Array.Empty(),
+ ApiMethods: Array.Empty());
+
+ var aggregate = new EntitySerializer().FromBundleContent(dto);
+
+ var sys = Assert.Single(aggregate.ExternalSystems);
+ Assert.Null(sys.AuthConfiguration);
+ }
+}