fix(transport): case-insensitive name lookups, batch preview lookups, correct flattener doc (ScadaBridge#93)
- ApplyTemplatesAsync/PreviewAsync template lookups use a case-insensitive, never-throwing comparer (SQL Server collation); base template wins over derived. Base names are already unique case-insensitively via the filtered unique index on Template.Name, derived templates are not covered by it. - Preview reads database connections, notification lists and API methods with one GetAll query each instead of one by-name query per bundled row. - Component-TemplateEngine.md: the flattener does not call GetAllTemplatesAsync; it loads chains via GetTemplateWithChildrenAsync. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
+176
@@ -0,0 +1,176 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
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.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
||||
|
||||
/// <summary>
|
||||
/// ScadaBridge#93: preview looked database connections, notification lists and API
|
||||
/// methods up one name at a time, and re-keyed template by-name results with an
|
||||
/// ordinal comparer although SQL Server matches names case-insensitively.
|
||||
/// </summary>
|
||||
public sealed class ImportNameLookupTests : IDisposable
|
||||
{
|
||||
private readonly ServiceProvider _provider;
|
||||
private readonly ConcurrentDictionary<string, int> _calls = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Initializes the test class with counting repositories over an in-memory database.</summary>
|
||||
public ImportNameLookupTests()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<Microsoft.AspNetCore.DataProtection.IDataProtectionProvider>(
|
||||
new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider());
|
||||
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().AddInMemoryCollection().Build());
|
||||
var dbName = $"ImportNameLookupTests_{Guid.NewGuid()}";
|
||||
services.AddDbContext<ScadaBridgeDbContext>(opts => opts
|
||||
.UseInMemoryDatabase(dbName)
|
||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
||||
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
|
||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||
services.AddScoped<IExternalSystemRepository>(sp =>
|
||||
ImportRoundTripBudgetTests.CountingProxy<IExternalSystemRepository>.Wrap(
|
||||
new ExternalSystemRepository(sp.GetRequiredService<ScadaBridgeDbContext>()), _calls));
|
||||
services.AddScoped<INotificationRepository>(sp =>
|
||||
ImportRoundTripBudgetTests.CountingProxy<INotificationRepository>.Wrap(
|
||||
new NotificationRepository(sp.GetRequiredService<ScadaBridgeDbContext>()), _calls));
|
||||
services.AddScoped<IInboundApiRepository>(sp =>
|
||||
ImportRoundTripBudgetTests.CountingProxy<IInboundApiRepository>.Wrap(
|
||||
new InboundApiRepository(sp.GetRequiredService<ScadaBridgeDbContext>()), _calls));
|
||||
services.AddScoped<ISiteRepository, SiteRepository>();
|
||||
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
||||
services.AddScoped<IAuditService, AuditService>();
|
||||
services.AddTransport();
|
||||
_provider = services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
/// <summary>Releases the resources used by this instance.</summary>
|
||||
public void Dispose() => _provider.Dispose();
|
||||
|
||||
private int Calls(string method) => _calls.TryGetValue(method, out var n) ? n : 0;
|
||||
|
||||
/// <summary>
|
||||
/// Preview of a bundle with several database connections, notification lists and
|
||||
/// API methods must read each library once (no per-name query) and must match the
|
||||
/// target's rows case-insensitively, as SQL Server's collation does.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Preview_batches_name_lookups_and_matches_case_insensitively()
|
||||
{
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
ctx.DatabaseConnectionDefinitions.Add(new DatabaseConnectionDefinition($"db-{i}", "Server=s;Database=d;"));
|
||||
ctx.NotificationLists.Add(new NotificationList($"list-{i}"));
|
||||
ctx.ApiMethods.Add(new ApiMethod($"Method{i}", "return 1;"));
|
||||
}
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
Stream bundle;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
bundle = await scope.ServiceProvider.GetRequiredService<IBundleExporter>().ExportAsync(
|
||||
new ExportSelection(
|
||||
TemplateIds: Array.Empty<int>(),
|
||||
SharedScriptIds: Array.Empty<int>(),
|
||||
ExternalSystemIds: Array.Empty<int>(),
|
||||
DatabaseConnectionIds: await ctx.DatabaseConnectionDefinitions.Select(x => x.Id).ToListAsync(),
|
||||
NotificationListIds: await ctx.NotificationLists.Select(x => x.Id).ToListAsync(),
|
||||
SmtpConfigurationIds: Array.Empty<int>(),
|
||||
ApiMethodIds: await ctx.ApiMethods.Select(x => x.Id).ToListAsync(),
|
||||
IncludeDependencies: false),
|
||||
user: "alice", sourceEnvironment: "dev", passphrase: null,
|
||||
cancellationToken: CancellationToken.None);
|
||||
}
|
||||
|
||||
Guid sessionId;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
sessionId = (await scope.ServiceProvider.GetRequiredService<IBundleImporter>()
|
||||
.LoadAsync(bundle, passphrase: null)).SessionId;
|
||||
}
|
||||
|
||||
// The target environment spells two of the names with different casing; the
|
||||
// production collation treats them as the SAME row.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
(await ctx.DatabaseConnectionDefinitions.SingleAsync(x => x.Name == "db-1")).Name = "DB-1";
|
||||
(await ctx.NotificationLists.SingleAsync(x => x.Name == "list-2")).Name = "LIST-2";
|
||||
(await ctx.ApiMethods.SingleAsync(x => x.Name == "Method3")).Name = "METHOD3";
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
_calls.Clear();
|
||||
|
||||
ImportPreview preview;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
preview = await scope.ServiceProvider.GetRequiredService<IBundleImporter>().PreviewAsync(sessionId);
|
||||
}
|
||||
|
||||
Assert.Equal(0, Calls(nameof(IExternalSystemRepository.GetDatabaseConnectionByNameAsync)));
|
||||
Assert.Equal(0, Calls(nameof(INotificationRepository.GetListByNameAsync)));
|
||||
Assert.Equal(0, Calls(nameof(IInboundApiRepository.GetMethodByNameAsync)));
|
||||
Assert.Equal(1, Calls(nameof(IExternalSystemRepository.GetAllDatabaseConnectionsAsync)));
|
||||
Assert.Equal(1, Calls(nameof(INotificationRepository.GetAllNotificationListsAsync)));
|
||||
Assert.Equal(1, Calls(nameof(IInboundApiRepository.GetAllApiMethodsAsync)));
|
||||
|
||||
foreach (var item in preview.Items.Where(i =>
|
||||
i.EntityType is "DatabaseConnection" or "NotificationList" or "ApiMethod"))
|
||||
{
|
||||
Assert.NotEqual(ConflictKind.New, item.Kind);
|
||||
}
|
||||
Assert.Equal(12, preview.Items.Count(i =>
|
||||
i.EntityType is "DatabaseConnection" or "NotificationList" or "ApiMethod"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two rows whose names differ only in case (possible for derived templates, which
|
||||
/// the filtered unique index on Name does not cover) must not throw, and a base
|
||||
/// template wins over a derived one.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Template_lookup_tolerates_names_differing_only_in_case()
|
||||
{
|
||||
var derived = new Template("Pump") { Id = 1, IsDerived = true };
|
||||
var other = new Template("pump") { Id = 2, IsDerived = true };
|
||||
var baseT = new Template("PUMP") { Id = 3, IsDerived = false };
|
||||
|
||||
var lookup = BundleImporter.ToTemplateLookup(new[] { derived, other, baseT });
|
||||
|
||||
Assert.Single(lookup);
|
||||
Assert.Same(baseT, lookup["pump"]);
|
||||
|
||||
var onlyDerived = BundleImporter.ToTemplateLookup(new[] { derived, other });
|
||||
Assert.Same(derived, onlyDerived["PUMP"]);
|
||||
}
|
||||
|
||||
/// <summary>A lookup built from by-name rows never throws on duplicate case variants.</summary>
|
||||
[Fact]
|
||||
public void Name_lookup_is_case_insensitive_and_first_wins()
|
||||
{
|
||||
var a = new NotificationList("List");
|
||||
var b = new NotificationList("LIST");
|
||||
var lookup = BundleImporter.ToNameLookup(new[] { a, b }, x => x.Name);
|
||||
Assert.Same(a, lookup["list"]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user