6ff5efd3f4
Item 2 of #100, following the #93/#95 pattern (ToNameLookup: OrdinalIgnoreCase, first wins, never throws): - Overwrite child sync matches template attributes, alarms, scripts and native alarm sources, and external-system methods, case-insensitively, like their (parent, Name) unique indexes under SQL Server's collation. A member that differs only in case is updated in place instead of deleted and re-added (which lost the row id and could collide with the unique index). - ResolveAlarmScriptLinksAsync resolves the alarm and its on-trigger script case-insensitively; AlarmsEqual compares the script name the same way. - ArtifactDiff (preview) matches child collections case-insensitively so it agrees with the sync; notification recipients keep the ordinal match (no unique index, replaced wholesale). - The rewire passes found the template a DTO was imported onto with an ordinal index. Since #93 an Overwrite can match a differently-cased target and keeps its spelling, so the passes silently skipped it and the alarm on-trigger links the sync had cleared stayed null. TryGetImported adds a case-insensitive (base-first) fallback; composed/base template references keep the exact lookup (item 1 of #100 is still undecided). Test: ImportNameLookupTests.Members_inside_a_record_match_case_insensitively, over a proxy that gives the by-name template reads SQL Server's collation. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
478 lines
24 KiB
C#
478 lines
24 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Reflection;
|
|
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.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;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
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>();
|
|
// The in-memory provider compares strings ordinally; SQL Server's collation does
|
|
// not. The proxy gives the by-name template reads the server's case-insensitive
|
|
// matching so a re-cased target behaves as it would in production.
|
|
services.AddScoped<ITemplateEngineRepository>(sp => CollationProxy.Wrap(
|
|
new TemplateEngineRepository(
|
|
sp.GetRequiredService<ScadaBridgeDbContext>(), sp.GetRequiredService<ITemplateGraphWatermark>()),
|
|
sp.GetRequiredService<ScadaBridgeDbContext>()));
|
|
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"]);
|
|
}
|
|
// ---- 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);
|
|
}
|
|
}
|
|
// ---- ScadaBridge#100: names inside a record ---------------------------------------
|
|
|
|
/// <summary>
|
|
/// Template members (attributes, alarms, scripts, native alarm sources) and external
|
|
/// system methods are unique per parent case-insensitively under SQL Server's
|
|
/// collation. A target whose member names differ from the bundle's only in case holds
|
|
/// the SAME members: preview must report them unchanged, and an Overwrite must keep
|
|
/// the rows (same ids, no delete + re-add) and the alarm's on-trigger script link —
|
|
/// also when the template name itself differs only in case.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Members_inside_a_record_match_case_insensitively()
|
|
{
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var template = new Template("Pump");
|
|
template.Attributes.Add(new TemplateAttribute("Speed") { Value = "1", DataType = DataType.Int32 });
|
|
template.Scripts.Add(new TemplateScript("OnTrip", "return 1;"));
|
|
template.Alarms.Add(new TemplateAlarm("HighTemp") { PriorityLevel = 1 });
|
|
template.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Boiler") { ConnectionName = "c", SourceReference = "r" });
|
|
ctx.Templates.Add(template);
|
|
var erp = new ExternalSystemDefinition("Erp", "http://erp", "None");
|
|
ctx.ExternalSystemDefinitions.Add(erp);
|
|
await ctx.SaveChangesAsync();
|
|
template.Alarms.Single().OnTriggerScriptId = template.Scripts.Single().Id;
|
|
ctx.ExternalSystemMethods.Add(new ExternalSystemMethod("GetOrder", "GET", "/orders") { ExternalSystemDefinitionId = erp.Id });
|
|
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: await ctx.ExternalSystemDefinitions.Select(x => x.Id).ToListAsync(),
|
|
DatabaseConnectionIds: Array.Empty<int>(),
|
|
NotificationListIds: Array.Empty<int>(),
|
|
SmtpConfigurationIds: Array.Empty<int>(),
|
|
ApiMethodIds: Array.Empty<int>(),
|
|
IncludeDependencies: false));
|
|
|
|
// Re-case every name on the target; ids are what an in-place update must keep.
|
|
int[] memberIds;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
(await ctx.Templates.SingleAsync()).Name = "PUMP";
|
|
var attribute = await ctx.TemplateAttributes.SingleAsync();
|
|
var script = await ctx.TemplateScripts.SingleAsync();
|
|
var alarm = await ctx.TemplateAlarms.SingleAsync();
|
|
var source = await ctx.TemplateNativeAlarmSources.SingleAsync();
|
|
var method = await ctx.ExternalSystemMethods.SingleAsync();
|
|
attribute.Name = "SPEED";
|
|
script.Name = "ontrip";
|
|
alarm.Name = "hightemp";
|
|
source.Name = "BOILER";
|
|
method.Name = "getorder";
|
|
await ctx.SaveChangesAsync();
|
|
memberIds = [attribute.Id, script.Id, alarm.Id, source.Id, method.Id];
|
|
}
|
|
|
|
var preview = await PreviewAsync(sessionId);
|
|
Assert.Equal(ConflictKind.Identical, Assert.Single(preview.Items, i => i.EntityType == "Template").Kind);
|
|
Assert.Equal(ConflictKind.Identical, Assert.Single(preview.Items, i => i.EntityType == "ExternalSystem").Kind);
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
await scope.ServiceProvider.GetRequiredService<IBundleImporter>().ApplyAsync(
|
|
sessionId,
|
|
[
|
|
new ImportResolution("Template", "Pump", ResolutionAction.Overwrite, null),
|
|
new ImportResolution("ExternalSystem", "Erp", ResolutionAction.Overwrite, null),
|
|
],
|
|
user: "bob");
|
|
}
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var attribute = await ctx.TemplateAttributes.SingleAsync();
|
|
var script = await ctx.TemplateScripts.SingleAsync();
|
|
var alarm = await ctx.TemplateAlarms.SingleAsync();
|
|
var source = await ctx.TemplateNativeAlarmSources.SingleAsync();
|
|
var method = await ctx.ExternalSystemMethods.SingleAsync();
|
|
Assert.Equal(memberIds, new[] { attribute.Id, script.Id, alarm.Id, source.Id, method.Id });
|
|
Assert.Equal(script.Id, alarm.OnTriggerScriptId);
|
|
Assert.DoesNotContain(await ctx.AuditLogEntries.Select(e => e.Action).ToListAsync(),
|
|
a => a.EndsWith("Deleted", StringComparison.Ordinal) || a.EndsWith("Added", StringComparison.Ordinal));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gives <see cref="ITemplateEngineRepository"/>'s by-name template reads SQL Server's
|
|
/// case-insensitive name matching over the in-memory provider: the requested names are
|
|
/// replaced by every stored template name equal to one of them ignoring case.
|
|
/// </summary>
|
|
public class CollationProxy : DispatchProxy
|
|
{
|
|
private ITemplateEngineRepository _inner = null!;
|
|
private ScadaBridgeDbContext _context = null!;
|
|
|
|
/// <summary>Creates the proxy over <paramref name="inner"/>.</summary>
|
|
/// <param name="inner">The real repository.</param>
|
|
/// <param name="context">The context the repository reads.</param>
|
|
/// <returns>The proxy.</returns>
|
|
public static ITemplateEngineRepository Wrap(ITemplateEngineRepository inner, ScadaBridgeDbContext context)
|
|
{
|
|
var proxy = Create<ITemplateEngineRepository, CollationProxy>();
|
|
var self = (CollationProxy)(object)proxy;
|
|
self._inner = inner;
|
|
self._context = context;
|
|
return proxy;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
|
|
{
|
|
if (targetMethod!.Name is nameof(ITemplateEngineRepository.GetTemplatesWithChildrenAsync)
|
|
or nameof(ITemplateEngineRepository.GetBaseTemplatesWithChildrenAsync)
|
|
&& args![0] is IEnumerable<string> names)
|
|
{
|
|
var wanted = names.Where(n => n is not null).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
args[0] = _context.Templates.AsNoTracking().Select(t => t.Name).AsEnumerable()
|
|
.Where(wanted.Contains).Distinct(StringComparer.Ordinal).ToList();
|
|
}
|
|
|
|
try
|
|
{
|
|
return targetMethod.Invoke(_inner, args);
|
|
}
|
|
catch (TargetInvocationException ex) when (ex.InnerException is not null)
|
|
{
|
|
throw ex.InnerException;
|
|
}
|
|
}
|
|
}
|
|
}
|