f98369a786
TemplateNativeAlarmSource.IsMesReportable + tri-state InstanceNativeAlarmSourceOverride .IsMesReportableOverride flatten onto ResolvedNativeAlarmSource; NativeAlarmActor stamps every mirrored condition and InstanceActor the quiet-binding placeholder, so ScriptAlarm, Alarms.MesReportableAsync() and the #60 wire fields carry it. Additive to the 900-999 severity band. Migration AddNativeAlarmSourceMesReportable. Management commands, CLI --mes-reportable, Central UI (template dialog checkbox, instance override select), Transport DTOs/importer/diff and the revision hash (true-only, so existing hashes are unchanged) carry it. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
274 lines
13 KiB
C#
274 lines
13 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
using Microsoft.EntityFrameworkCore.Metadata;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
|
|
|
public class SchemaConfigurationTests : IDisposable
|
|
{
|
|
private readonly ScadaBridgeDbContext _context;
|
|
|
|
/// <summary>Creates an in-memory SQLite context for each test.</summary>
|
|
public SchemaConfigurationTests()
|
|
{
|
|
_context = SqliteTestHelper.CreateInMemoryContext();
|
|
}
|
|
|
|
/// <summary>Releases the resources held by this instance.</summary>
|
|
public void Dispose()
|
|
{
|
|
_context.Database.CloseConnection();
|
|
_context.Dispose();
|
|
}
|
|
|
|
// The gRPC node-address columns must be length-bounded
|
|
// (HasMaxLength(500)) consistently with the sibling NodeAAddress/NodeBAddress columns,
|
|
// rather than being left to map to nvarchar(max).
|
|
|
|
/// <summary>Verifies that the gRPC node address columns are limited to 500 characters.</summary>
|
|
/// <param name="propertyName">The gRPC node address property under test.</param>
|
|
[Theory]
|
|
[InlineData(nameof(Site.GrpcNodeAAddress))]
|
|
[InlineData(nameof(Site.GrpcNodeBAddress))]
|
|
public void GrpcNodeAddressColumns_AreLengthBoundedTo500(string propertyName)
|
|
{
|
|
var property = _context.Model
|
|
.FindEntityType(typeof(Site))!
|
|
.FindProperty(propertyName)!;
|
|
|
|
Assert.Equal(500, property.GetMaxLength());
|
|
}
|
|
|
|
/// <summary>Verifies that the gRPC node address columns match the length bound of the sibling node address columns.</summary>
|
|
/// <param name="siblingPropertyName">The sibling node address property to compare against.</param>
|
|
[Theory]
|
|
[InlineData(nameof(Site.NodeAAddress))]
|
|
[InlineData(nameof(Site.NodeBAddress))]
|
|
public void GrpcNodeAddressColumns_MatchSiblingNodeAddressBounds(string siblingPropertyName)
|
|
{
|
|
var entity = _context.Model.FindEntityType(typeof(Site))!;
|
|
var siblingMaxLength = entity.FindProperty(siblingPropertyName)!.GetMaxLength();
|
|
|
|
Assert.Equal(siblingMaxLength, entity.FindProperty(nameof(Site.GrpcNodeAAddress))!.GetMaxLength());
|
|
Assert.Equal(siblingMaxLength, entity.FindProperty(nameof(Site.GrpcNodeBAddress))!.GetMaxLength());
|
|
}
|
|
|
|
// The encrypting value converter must be applied
|
|
// uniformly to all three secret-bearing columns, including the non-nullable
|
|
// DatabaseConnectionDefinition.ConnectionString. A regression here (e.g. the
|
|
// converter dropped from one HasConversion call) would silently store a secret
|
|
// in plaintext.
|
|
|
|
/// <summary>Verifies that every secret-bearing column has the encrypted string converter applied.</summary>
|
|
/// <param name="entityType">The entity type that owns the column.</param>
|
|
/// <param name="propertyName">The secret-bearing property under test.</param>
|
|
[Theory]
|
|
[InlineData(typeof(SmtpConfiguration), nameof(SmtpConfiguration.Credentials))]
|
|
[InlineData(typeof(ExternalSystemDefinition), nameof(ExternalSystemDefinition.AuthConfiguration))]
|
|
[InlineData(typeof(DatabaseConnectionDefinition), nameof(DatabaseConnectionDefinition.ConnectionString))]
|
|
public void SecretColumns_AllHaveEncryptedStringConverterApplied(Type entityType, string propertyName)
|
|
{
|
|
var converter = _context.Model
|
|
.FindEntityType(entityType)!
|
|
.FindProperty(propertyName)!
|
|
.GetValueConverter();
|
|
|
|
Assert.IsType<EncryptedStringConverter>(converter);
|
|
}
|
|
|
|
// arch-review 04 (P1): the SiteCalls "live queue" KPI predicates all filter on
|
|
// TerminalAtUtc IS NULL and run every 60 s (KPI recorder) + every 10 s (Health poll).
|
|
// A filtered index over just the non-terminal rows turns each count from a clustered
|
|
// scan of the 365-day archive into a small seek. Locked shape:
|
|
// key = CreatedAtUtc, filter = [TerminalAtUtc] IS NULL, INCLUDE SourceSite/SourceNode/Status.
|
|
/// <summary>Verifies that the site calls table has a filtered index over non-terminal rows.</summary>
|
|
[Fact]
|
|
public void SiteCalls_Has_Filtered_NonTerminal_Index()
|
|
{
|
|
// IncludeProperties is a SQL-Server-only annotation stripped from the runtime
|
|
// read-optimized model, so read the design-time model where it is preserved.
|
|
var model = _context.GetService<IDesignTimeModel>().Model;
|
|
var entity = model.FindEntityType(typeof(SiteCall))!;
|
|
var index = entity.GetIndexes().Single(i => i.GetDatabaseName() == "IX_SiteCalls_NonTerminal");
|
|
|
|
Assert.Equal(new[] { nameof(SiteCall.CreatedAtUtc) }, index.Properties.Select(p => p.Name));
|
|
Assert.Equal("[TerminalAtUtc] IS NULL", index.GetFilter());
|
|
Assert.Equal(
|
|
new[] { nameof(SiteCall.SourceSite), nameof(SiteCall.SourceNode), nameof(SiteCall.Status) },
|
|
index.GetIncludeProperties());
|
|
}
|
|
|
|
// arch-review 04 round 2 (R6): the daily terminal retention purge filters on
|
|
// TerminalAtUtc IS NOT NULL AND TerminalAtUtc < cutoff. A filtered index over the
|
|
// terminal population lets the purge (and the MIN() slice anchor) seek the
|
|
// expired tail instead of full-scanning the 365-day table. Locked shape:
|
|
// key = TerminalAtUtc, filter = [TerminalAtUtc] IS NOT NULL.
|
|
/// <summary>Verifies that the site calls table has a filtered index over terminal rows.</summary>
|
|
[Fact]
|
|
public void SiteCalls_Has_Filtered_Terminal_Index()
|
|
{
|
|
var model = _context.GetService<IDesignTimeModel>().Model;
|
|
var entity = model.FindEntityType(typeof(SiteCall))!;
|
|
var index = entity.GetIndexes().Single(i => i.GetDatabaseName() == "IX_SiteCalls_Terminal");
|
|
|
|
Assert.Equal(new[] { nameof(SiteCall.TerminalAtUtc) }, index.Properties.Select(p => p.Name).ToArray());
|
|
Assert.Equal("[TerminalAtUtc] IS NOT NULL", index.GetFilter());
|
|
}
|
|
|
|
// MES-reportable alarm flag: TemplateAlarm.IsMesReportable (bool, non-nullable,
|
|
// defaults false) and the nullable per-instance InstanceAlarmOverride
|
|
// .IsMesReportableOverride (null = inherit) must both round-trip through the
|
|
// configuration database. Backed on SQL Server by the AddAlarmMesReportable
|
|
// migration; pinned here on the model/persist path.
|
|
/// <summary>Verifies that template alarms and their overrides persist the MES-reportable flags.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task TemplateAlarm_And_Override_PersistMesReportableFlags()
|
|
{
|
|
var site = new Site("Site1", "S-001");
|
|
var template = new Template("MesAlarmT1");
|
|
_context.Sites.Add(site);
|
|
_context.Templates.Add(template);
|
|
await _context.SaveChangesAsync();
|
|
|
|
var instance = new Instance("MesAlarmInst1") { SiteId = site.Id, TemplateId = template.Id };
|
|
_context.Instances.Add(instance);
|
|
await _context.SaveChangesAsync();
|
|
|
|
var alarm = new TemplateAlarm("HighTemp")
|
|
{
|
|
TemplateId = template.Id,
|
|
TriggerType = AlarmTriggerType.HiLo,
|
|
PriorityLevel = 500,
|
|
IsMesReportable = true
|
|
};
|
|
var ovr = new InstanceAlarmOverride("HighTemp")
|
|
{
|
|
InstanceId = instance.Id,
|
|
IsMesReportableOverride = false
|
|
};
|
|
_context.TemplateAlarms.Add(alarm);
|
|
_context.InstanceAlarmOverrides.Add(ovr);
|
|
await _context.SaveChangesAsync();
|
|
_context.ChangeTracker.Clear();
|
|
|
|
Assert.True((await _context.TemplateAlarms.SingleAsync(a => a.Id == alarm.Id)).IsMesReportable);
|
|
Assert.False((await _context.InstanceAlarmOverrides.SingleAsync(o => o.Id == ovr.Id)).IsMesReportableOverride);
|
|
}
|
|
|
|
// Gitea #59: per-binding flag on native alarm sources. Backed on SQL Server by the
|
|
// AddNativeAlarmSourceMesReportable migration; pinned here on the model/persist path.
|
|
/// <summary>Verifies that native alarm sources and their overrides persist the MES-reportable flags.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task NativeAlarmSource_And_Override_PersistMesReportableFlags()
|
|
{
|
|
var site = new Site("Site59", "S-059");
|
|
var template = new Template("MesNativeT1");
|
|
_context.Sites.Add(site);
|
|
_context.Templates.Add(template);
|
|
await _context.SaveChangesAsync();
|
|
|
|
var instance = new Instance("MesNativeInst1") { SiteId = site.Id, TemplateId = template.Id };
|
|
_context.Instances.Add(instance);
|
|
await _context.SaveChangesAsync();
|
|
|
|
var src = new TemplateNativeAlarmSource("Pressure")
|
|
{ TemplateId = template.Id, ConnectionName = "Opc", SourceReference = "ns=2;s=P", IsMesReportable = true };
|
|
var ovr = new InstanceNativeAlarmSourceOverride("Pressure")
|
|
{ InstanceId = instance.Id, IsMesReportableOverride = false };
|
|
_context.Add(src);
|
|
_context.Add(ovr);
|
|
await _context.SaveChangesAsync();
|
|
_context.ChangeTracker.Clear();
|
|
|
|
Assert.True((await _context.Set<TemplateNativeAlarmSource>().SingleAsync(a => a.Id == src.Id)).IsMesReportable);
|
|
Assert.False((await _context.Set<InstanceNativeAlarmSourceOverride>().SingleAsync(o => o.Id == ovr.Id)).IsMesReportableOverride);
|
|
}
|
|
}
|
|
|
|
public class SplitQueryBehaviourTests : IDisposable
|
|
{
|
|
private readonly ScadaBridgeDbContext _context;
|
|
private readonly TemplateEngineRepository _repository;
|
|
|
|
/// <summary>Creates an in-memory SQLite context and a template repository for each test.</summary>
|
|
public SplitQueryBehaviourTests()
|
|
{
|
|
_context = SqliteTestHelper.CreateInMemoryContext();
|
|
_repository = new TemplateEngineRepository(_context, new TemplateGraphWatermark());
|
|
}
|
|
|
|
/// <summary>Releases the resources held by this instance.</summary>
|
|
public void Dispose()
|
|
{
|
|
_context.Database.CloseConnection();
|
|
_context.Dispose();
|
|
}
|
|
|
|
// The multi-collection eager-load queries were switched to
|
|
// AsSplitQuery() to avoid cartesian-product joins. The result set must be unchanged —
|
|
// every member collection still fully populated, with no row duplication.
|
|
|
|
/// <summary>Verifies that loading all templates with several members per collection returns every member without duplication.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task GetAllTemplatesAsync_WithMultipleMembersPerCollection_LoadsAllWithoutDuplication()
|
|
{
|
|
var template = new Template("MultiMember");
|
|
for (int i = 0; i < 3; i++)
|
|
template.Attributes.Add(new TemplateAttribute($"Attr{i}"));
|
|
for (int i = 0; i < 2; i++)
|
|
template.Alarms.Add(new TemplateAlarm($"Alarm{i}"));
|
|
for (int i = 0; i < 4; i++)
|
|
template.Scripts.Add(new TemplateScript($"Script{i}", "return 1;"));
|
|
_context.Templates.Add(template);
|
|
await _context.SaveChangesAsync();
|
|
_context.ChangeTracker.Clear();
|
|
|
|
var all = await _repository.GetAllTemplatesAsync();
|
|
|
|
var loaded = Assert.Single(all);
|
|
// A cartesian-product single query would yield 3 x 2 x 4 = 24 joined rows; the
|
|
// collections must still contain exactly the inserted counts.
|
|
Assert.Equal(3, loaded.Attributes.Count);
|
|
Assert.Equal(2, loaded.Alarms.Count);
|
|
Assert.Equal(4, loaded.Scripts.Count);
|
|
}
|
|
|
|
/// <summary>Verifies that loading a template by id with several members populates all of its collections.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task GetTemplateByIdAsync_WithMultipleMembers_LoadsAllCollections()
|
|
{
|
|
var template = new Template("Single");
|
|
template.Attributes.Add(new TemplateAttribute("A1"));
|
|
template.Attributes.Add(new TemplateAttribute("A2"));
|
|
template.Scripts.Add(new TemplateScript("S1", "return 1;"));
|
|
_context.Templates.Add(template);
|
|
await _context.SaveChangesAsync();
|
|
_context.ChangeTracker.Clear();
|
|
|
|
var loaded = await _repository.GetTemplateByIdAsync(template.Id);
|
|
|
|
Assert.NotNull(loaded);
|
|
Assert.Equal(2, loaded!.Attributes.Count);
|
|
Assert.Single(loaded.Scripts);
|
|
}
|
|
|
|
// Auth re-arch (C5): the SQL Server ApiKey entity was retired (inbound keys now
|
|
// live in the shared ZB.MOM.WW.Auth.ApiKeys SQLite store), so the former
|
|
// ApiKey_KeyHashColumn_IsMappedAndUniquelyIndexed and
|
|
// ApiKey_HasNoPlaintextKeyValueColumn schema assertions were removed with it.
|
|
}
|