fix(transport): case-insensitive remaining library lookups; base-only template reads for import targets (ScadaBridge#95)
- Preview matches template folders, shared scripts, external systems, SMTP (by Host) and SMS (by AccountSid) with the #93 ToNameLookup comparer (OrdinalIgnoreCase, first wins, never throws), matching SQL Server's case-insensitive collation, so a differently-cased target row is reported as a conflict instead of New. - Apply-side in-memory lookups do the same: ApplyTemplateFoldersAsync, the template folder-id map, ResolveFolderParentEdgesAsync, ApplySmtpConfigsAsync and ApplySmsConfigsAsync. An Overwrite now updates the existing row instead of adding a duplicate (folder names are only unique per parent, so the old ToDictionary could also throw). - New ITemplateEngineRepository.GetBaseTemplatesWithChildrenAsync filters IsDerived in SQL. Preview and the Overwrite load in ApplyTemplatesAsync use it, so a derived slot template that shares a bundled name is never the import target. GetTemplatesWithChildrenAsync is unchanged for the id-keyed callers (export closure, rewire passes), which can need derived rows. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
+21
@@ -221,6 +221,27 @@ public class TemplateEngineRepositoryTests : IDisposable
|
||||
Assert.Equal("Alpha", result[0].Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ScadaBridge#95: the base-only bulk variant skips derived templates that share a
|
||||
/// requested name, while the general variant still returns them.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetBaseTemplatesWithChildrenAsync_SkipsDerivedRowsSharingTheName()
|
||||
{
|
||||
var baseT = new Template("Alpha");
|
||||
var derived = new Template("Alpha") { IsDerived = true };
|
||||
var derivedOnly = new Template("Beta") { IsDerived = true };
|
||||
_context.Templates.AddRange(baseT, derived, derivedOnly);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var baseOnly = await _repository.GetBaseTemplatesWithChildrenAsync(new[] { "Alpha", "Beta" });
|
||||
var all = await _repository.GetTemplatesWithChildrenAsync(new[] { "Alpha", "Beta" });
|
||||
|
||||
Assert.Equal(baseT.Id, Assert.Single(baseOnly).Id);
|
||||
Assert.Equal(3, all.Count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the parent template id is preserved so the inheritance chain can be walked.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
|
||||
+2
-1
@@ -149,7 +149,8 @@ public sealed class ImportApplyScalingTests
|
||||
return new ApplyCost(
|
||||
detectChanges,
|
||||
target.CallsTo(nameof(ITemplateEngineRepository.GetTemplateWithChildrenAsync)),
|
||||
target.CallsTo(nameof(ITemplateEngineRepository.GetTemplatesWithChildrenAsync)),
|
||||
target.CallsTo(nameof(ITemplateEngineRepository.GetTemplatesWithChildrenAsync))
|
||||
+ target.CallsTo(nameof(ITemplateEngineRepository.GetBaseTemplatesWithChildrenAsync)),
|
||||
result);
|
||||
}
|
||||
|
||||
|
||||
+157
@@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
@@ -173,4 +174,160 @@ public sealed class ImportNameLookupTests : IDisposable
|
||||
var lookup = BundleImporter.ToNameLookup(new[] { a, b }, x => x.Name);
|
||||
Assert.Same(a, lookup["list"]);
|
||||
}
|
||||
// ---- ScadaBridge#95: the remaining libraries + derived templates ------------------
|
||||
|
||||
private async Task<Guid> ExportAndLoadAsync(Func<ScadaBridgeDbContext, Task<ExportSelection>> select)
|
||||
{
|
||||
Stream bundle;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
bundle = await scope.ServiceProvider.GetRequiredService<IBundleExporter>().ExportAsync(
|
||||
await select(ctx), user: "alice", sourceEnvironment: "dev", passphrase: null,
|
||||
cancellationToken: CancellationToken.None);
|
||||
}
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
return (await scope.ServiceProvider.GetRequiredService<IBundleImporter>()
|
||||
.LoadAsync(bundle, passphrase: null)).SessionId;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ImportPreview> PreviewAsync(Guid sessionId)
|
||||
{
|
||||
await using var scope = _provider.CreateAsyncScope();
|
||||
return await scope.ServiceProvider.GetRequiredService<IBundleImporter>().PreviewAsync(sessionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Folders, shared scripts, external systems, SMTP and SMS configurations whose target
|
||||
/// spelling differs only in case are the SAME rows under SQL Server's collation: preview
|
||||
/// must report them as conflicts (not New), and an Overwrite must update them in place
|
||||
/// instead of adding a second row.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Preview_and_apply_match_the_remaining_libraries_case_insensitively()
|
||||
{
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var folder = new TemplateFolder("pumps");
|
||||
ctx.TemplateFolders.Add(folder);
|
||||
await ctx.SaveChangesAsync();
|
||||
ctx.Templates.Add(new Template("Pump") { FolderId = folder.Id });
|
||||
ctx.SharedScripts.Add(new SharedScript("Helper", "return 1;"));
|
||||
ctx.ExternalSystemDefinitions.Add(new ExternalSystemDefinition("Erp", "http://erp", "None"));
|
||||
ctx.SmtpConfigurations.Add(new SmtpConfiguration("mail.local", "None", "a@b.c"));
|
||||
ctx.SmsConfigurations.Add(new SmsConfiguration("AC123"));
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sessionId = await ExportAndLoadAsync(async ctx => new ExportSelection(
|
||||
TemplateIds: await ctx.Templates.Select(x => x.Id).ToListAsync(),
|
||||
SharedScriptIds: await ctx.SharedScripts.Select(x => x.Id).ToListAsync(),
|
||||
ExternalSystemIds: await ctx.ExternalSystemDefinitions.Select(x => x.Id).ToListAsync(),
|
||||
DatabaseConnectionIds: Array.Empty<int>(),
|
||||
NotificationListIds: Array.Empty<int>(),
|
||||
SmtpConfigurationIds: await ctx.SmtpConfigurations.Select(x => x.Id).ToListAsync(),
|
||||
ApiMethodIds: Array.Empty<int>(),
|
||||
IncludeDependencies: false,
|
||||
SmsConfigurationIds: await ctx.SmsConfigurations.Select(x => x.Id).ToListAsync()));
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
(await ctx.TemplateFolders.SingleAsync()).Name = "PUMPS";
|
||||
(await ctx.SharedScripts.SingleAsync()).Name = "HELPER";
|
||||
(await ctx.ExternalSystemDefinitions.SingleAsync()).Name = "ERP";
|
||||
(await ctx.SmtpConfigurations.SingleAsync()).Host = "MAIL.LOCAL";
|
||||
(await ctx.SmsConfigurations.SingleAsync()).AccountSid = "ac123";
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var preview = await PreviewAsync(sessionId);
|
||||
string[] types = ["TemplateFolder", "SharedScript", "ExternalSystem", "SmtpConfiguration", "SmsConfiguration"];
|
||||
foreach (var type in types)
|
||||
{
|
||||
var item = Assert.Single(preview.Items, i => i.EntityType == type);
|
||||
Assert.NotEqual(ConflictKind.New, item.Kind);
|
||||
}
|
||||
|
||||
var resolutions = preview.Items
|
||||
.Where(i => i.EntityType is "TemplateFolder" or "SmtpConfiguration" or "SmsConfiguration")
|
||||
.Select(i => new ImportResolution(i.EntityType, i.Name, ResolutionAction.Overwrite, null))
|
||||
.Concat(preview.Items
|
||||
.Where(i => i.EntityType is not ("TemplateFolder" or "SmtpConfiguration" or "SmsConfiguration"))
|
||||
.Select(i => new ImportResolution(i.EntityType, i.Name, ResolutionAction.Skip, null)))
|
||||
.ToList();
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
await scope.ServiceProvider.GetRequiredService<IBundleImporter>()
|
||||
.ApplyAsync(sessionId, resolutions, user: "bob");
|
||||
}
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
Assert.Equal(1, await ctx.TemplateFolders.CountAsync());
|
||||
Assert.Equal(1, await ctx.SmtpConfigurations.CountAsync());
|
||||
Assert.Equal(1, await ctx.SmsConfigurations.CountAsync());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A derived template carries its composition slot name; a bundled template of that name
|
||||
/// targets a BASE template, so a target whose only row of that name is derived must see
|
||||
/// the bundled template as New, and applying it must add a base template rather than
|
||||
/// overwrite the derived one.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task A_derived_template_sharing_the_bundled_name_is_not_the_import_target()
|
||||
{
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.Templates.Add(new Template("Motor"));
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sessionId = await ExportAndLoadAsync(async ctx => new ExportSelection(
|
||||
TemplateIds: await ctx.Templates.Select(x => x.Id).ToListAsync(),
|
||||
SharedScriptIds: Array.Empty<int>(),
|
||||
ExternalSystemIds: Array.Empty<int>(),
|
||||
DatabaseConnectionIds: Array.Empty<int>(),
|
||||
NotificationListIds: Array.Empty<int>(),
|
||||
SmtpConfigurationIds: Array.Empty<int>(),
|
||||
ApiMethodIds: Array.Empty<int>(),
|
||||
IncludeDependencies: false));
|
||||
|
||||
// The target's only "Motor" is a derived slot template.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
(await ctx.Templates.SingleAsync()).IsDerived = true;
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var preview = await PreviewAsync(sessionId);
|
||||
Assert.Equal(ConflictKind.New, Assert.Single(preview.Items, i => i.EntityType == "Template").Kind);
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
await scope.ServiceProvider.GetRequiredService<IBundleImporter>().ApplyAsync(
|
||||
sessionId,
|
||||
[new ImportResolution("Template", "Motor", ResolutionAction.Overwrite, null)],
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var motors = await ctx.Templates.Where(t => t.Name == "Motor").ToListAsync();
|
||||
Assert.Equal(2, motors.Count);
|
||||
Assert.Single(motors, t => !t.IsDerived);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user