fix(configuration-database): resolve ConfigurationDatabase-005,006,008,009,010,011 — bounded gRPC columns, split queries, CSV-parse logging, null guards, coverage
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ScadaLink.Commons.Entities.InboundApi;
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
using ScadaLink.ConfigurationDatabase.Repositories;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Tests;
|
||||
|
||||
public class InboundApiRepositoryTests : IDisposable
|
||||
{
|
||||
private readonly ScadaLinkDbContext _context;
|
||||
private readonly CapturingLogger<InboundApiRepository> _logger = new();
|
||||
private readonly InboundApiRepository _repository;
|
||||
|
||||
public InboundApiRepositoryTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
_repository = new InboundApiRepository(_context, _logger);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddApiKey_AndGetById_RoundTrips()
|
||||
{
|
||||
var key = new ApiKey("Key1", "secret-value-1") { IsEnabled = true };
|
||||
await _repository.AddApiKeyAsync(key);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var loaded = await _repository.GetApiKeyByIdAsync(key.Id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("Key1", loaded!.Name);
|
||||
|
||||
var byValue = await _repository.GetApiKeyByValueAsync("secret-value-1");
|
||||
Assert.NotNull(byValue);
|
||||
Assert.Equal(key.Id, byValue!.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddApiMethod_AndGetByName_RoundTrips()
|
||||
{
|
||||
var method = new ApiMethod("DoThing", "return 1;");
|
||||
await _repository.AddApiMethodAsync(method);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var loaded = await _repository.GetMethodByNameAsync("DoThing");
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(method.Id, loaded!.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetApprovedKeysForMethod_WithValidCsv_ReturnsAllKeys()
|
||||
{
|
||||
var k1 = new ApiKey("K1", "v1");
|
||||
var k2 = new ApiKey("K2", "v2");
|
||||
await _repository.AddApiKeyAsync(k1);
|
||||
await _repository.AddApiKeyAsync(k2);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var method = new ApiMethod("M", "return 1;") { ApprovedApiKeyIds = $"{k1.Id}, {k2.Id}" };
|
||||
await _repository.AddApiMethodAsync(method);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var keys = await _repository.GetApprovedKeysForMethodAsync(method.Id);
|
||||
|
||||
Assert.Equal(2, keys.Count);
|
||||
Assert.Empty(_logger.Warnings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetApprovedKeysForMethod_WithMalformedCsvToken_LogsWarningAndDropsToken()
|
||||
{
|
||||
// Regression guard for ConfigurationDatabase-008: a corrupt token (a name where an
|
||||
// integer id is expected) must not be dropped silently — the corruption must be
|
||||
// observable via a logged warning, while the valid ids still resolve.
|
||||
var k1 = new ApiKey("K1", "v1");
|
||||
await _repository.AddApiKeyAsync(k1);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var method = new ApiMethod("M", "return 1;") { ApprovedApiKeyIds = $"{k1.Id},not-an-id" };
|
||||
await _repository.AddApiMethodAsync(method);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var keys = await _repository.GetApprovedKeysForMethodAsync(method.Id);
|
||||
|
||||
Assert.Single(keys);
|
||||
Assert.Equal(k1.Id, keys[0].Id);
|
||||
Assert.Single(_logger.Warnings);
|
||||
Assert.Contains("not-an-id", _logger.Warnings[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetApprovedKeysForMethod_WithNullOrEmptyCsv_ReturnsEmptyWithoutWarning()
|
||||
{
|
||||
var method = new ApiMethod("M", "return 1;");
|
||||
await _repository.AddApiMethodAsync(method);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var keys = await _repository.GetApprovedKeysForMethodAsync(method.Id);
|
||||
|
||||
Assert.Empty(keys);
|
||||
Assert.Empty(_logger.Warnings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteApiMethod_RemovesEntity()
|
||||
{
|
||||
var method = new ApiMethod("ToDelete", "return 1;");
|
||||
await _repository.AddApiMethodAsync(method);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
await _repository.DeleteApiMethodAsync(method.Id);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
Assert.Null(await _repository.GetApiMethodByIdAsync(method.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullContext_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new InboundApiRepository(null!));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Minimal ILogger that captures warning-level messages for assertions.</summary>
|
||||
internal sealed class CapturingLogger<T> : ILogger<T>
|
||||
{
|
||||
public List<string> Warnings { get; } = new();
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (logLevel == LogLevel.Warning)
|
||||
{
|
||||
Warnings.Add(formatter(state, exception));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static readonly NullScope Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ScadaLink.Commons.Entities.Deployment;
|
||||
using ScadaLink.Commons.Entities.ExternalSystems;
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Entities.Notifications;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
using ScadaLink.ConfigurationDatabase.Repositories;
|
||||
using ScadaLink.ConfigurationDatabase.Services;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Tests;
|
||||
|
||||
// Regression coverage for ConfigurationDatabase-010 (repositories / InstanceLocator lacked
|
||||
// direct tests) and ConfigurationDatabase-011 (inconsistent constructor null-guarding).
|
||||
|
||||
public class ExternalSystemRepositoryTests : IDisposable
|
||||
{
|
||||
private readonly ScadaLinkDbContext _context;
|
||||
private readonly ExternalSystemRepository _repository;
|
||||
|
||||
public ExternalSystemRepositoryTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
_repository = new ExternalSystemRepository(_context);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddExternalSystem_AndGetById_RoundTrips()
|
||||
{
|
||||
var def = new ExternalSystemDefinition("Sys", "https://example.test", "ApiKey");
|
||||
await _repository.AddExternalSystemAsync(def);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var loaded = await _repository.GetExternalSystemByIdAsync(def.Id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("Sys", loaded!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMethodsByExternalSystemId_FiltersByParent()
|
||||
{
|
||||
var def = new ExternalSystemDefinition("Sys", "https://example.test", "ApiKey");
|
||||
await _repository.AddExternalSystemAsync(def);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
await _repository.AddExternalSystemMethodAsync(
|
||||
new ExternalSystemMethod("M1", "GET", "/m1") { ExternalSystemDefinitionId = def.Id });
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var methods = await _repository.GetMethodsByExternalSystemIdAsync(def.Id);
|
||||
Assert.Single(methods);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteDatabaseConnection_RemovesEntity()
|
||||
{
|
||||
var conn = new DatabaseConnectionDefinition("Db", "Server=x;Database=y;");
|
||||
await _repository.AddDatabaseConnectionAsync(conn);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
await _repository.DeleteDatabaseConnectionAsync(conn.Id);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
Assert.Null(await _repository.GetDatabaseConnectionByIdAsync(conn.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullContext_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new ExternalSystemRepository(null!));
|
||||
}
|
||||
}
|
||||
|
||||
public class NotificationRepositoryTests : IDisposable
|
||||
{
|
||||
private readonly ScadaLinkDbContext _context;
|
||||
private readonly NotificationRepository _repository;
|
||||
|
||||
public NotificationRepositoryTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
_repository = new NotificationRepository(_context);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddNotificationList_WithRecipients_RoundTrips()
|
||||
{
|
||||
var list = new NotificationList("Ops");
|
||||
list.Recipients.Add(new NotificationRecipient("Ops Team", "ops@example.test"));
|
||||
await _repository.AddNotificationListAsync(list);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var loaded = await _repository.GetListByNameAsync("Ops");
|
||||
Assert.NotNull(loaded);
|
||||
|
||||
var all = await _repository.GetAllNotificationListsAsync();
|
||||
Assert.Single(all);
|
||||
Assert.Single(all[0].Recipients);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddSmtpConfiguration_AndGetById_RoundTrips()
|
||||
{
|
||||
var smtp = new SmtpConfiguration("smtp.example.test", "Basic", "from@example.test");
|
||||
await _repository.AddSmtpConfigurationAsync(smtp);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var loaded = await _repository.GetSmtpConfigurationByIdAsync(smtp.Id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("smtp.example.test", loaded!.Host);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteNotificationList_RemovesEntity()
|
||||
{
|
||||
var list = new NotificationList("ToDelete");
|
||||
await _repository.AddNotificationListAsync(list);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
await _repository.DeleteNotificationListAsync(list.Id);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
Assert.Null(await _repository.GetNotificationListByIdAsync(list.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullContext_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new NotificationRepository(null!));
|
||||
}
|
||||
}
|
||||
|
||||
public class SiteRepositoryTests : IDisposable
|
||||
{
|
||||
private readonly ScadaLinkDbContext _context;
|
||||
private readonly SiteRepository _repository;
|
||||
|
||||
public SiteRepositoryTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
_repository = new SiteRepository(_context);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddSite_AndGetByIdentifier_RoundTrips()
|
||||
{
|
||||
var site = new Site("Site1", "S-001");
|
||||
await _repository.AddSiteAsync(site);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var loaded = await _repository.GetSiteByIdentifierAsync("S-001");
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("Site1", loaded!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSite_ViaStubAttachPath_RemovesEntity()
|
||||
{
|
||||
// Exercises the stub-attach delete fallback: the entity is not tracked because the
|
||||
// ChangeTracker is cleared, forcing the Local-miss branch in DeleteSiteAsync.
|
||||
var site = new Site("Site1", "S-001");
|
||||
await _repository.AddSiteAsync(site);
|
||||
await _repository.SaveChangesAsync();
|
||||
var id = site.Id;
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
await _repository.DeleteSiteAsync(id);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
Assert.Null(await _repository.GetSiteByIdAsync(id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteDataConnection_ViaStubAttachPath_RemovesEntity()
|
||||
{
|
||||
var site = new Site("Site1", "S-001");
|
||||
await _repository.AddSiteAsync(site);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var conn = new DataConnection("Conn1", "OpcUa", site.Id);
|
||||
await _repository.AddDataConnectionAsync(conn);
|
||||
await _repository.SaveChangesAsync();
|
||||
var id = conn.Id;
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
await _repository.DeleteDataConnectionAsync(id);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
Assert.Null(await _repository.GetDataConnectionByIdAsync(id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetInstancesBySiteId_FiltersBySite()
|
||||
{
|
||||
var site = new Site("Site1", "S-001");
|
||||
var template = new Template("T1");
|
||||
_context.Sites.Add(site);
|
||||
_context.Templates.Add(template);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_context.Instances.Add(new Instance("I1") { SiteId = site.Id, TemplateId = template.Id });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var instances = await _repository.GetInstancesBySiteIdAsync(site.Id);
|
||||
Assert.Single(instances);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullContext_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new SiteRepository(null!));
|
||||
}
|
||||
}
|
||||
|
||||
public class DeploymentManagerRepositoryTests : IDisposable
|
||||
{
|
||||
private readonly ScadaLinkDbContext _context;
|
||||
private readonly DeploymentManagerRepository _repository;
|
||||
|
||||
public DeploymentManagerRepositoryTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
_repository = new DeploymentManagerRepository(_context);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
private async Task<Instance> SeedInstanceAsync()
|
||||
{
|
||||
var site = new Site("Site1", "S-001");
|
||||
var template = new Template("T1");
|
||||
_context.Sites.Add(site);
|
||||
_context.Templates.Add(template);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var instance = new Instance("Inst1") { SiteId = site.Id, TemplateId = template.Id };
|
||||
_context.Instances.Add(instance);
|
||||
await _context.SaveChangesAsync();
|
||||
return instance;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddDeploymentRecord_AndGetCurrentStatus_ReturnsMostRecent()
|
||||
{
|
||||
var instance = await SeedInstanceAsync();
|
||||
|
||||
await _repository.AddDeploymentRecordAsync(
|
||||
new DeploymentRecord("d-001", "admin") { InstanceId = instance.Id, DeployedAt = DateTimeOffset.UtcNow.AddHours(-1) });
|
||||
await _repository.AddDeploymentRecordAsync(
|
||||
new DeploymentRecord("d-002", "admin") { InstanceId = instance.Id, DeployedAt = DateTimeOffset.UtcNow });
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var current = await _repository.GetCurrentDeploymentStatusAsync(instance.Id);
|
||||
Assert.NotNull(current);
|
||||
Assert.Equal("d-002", current!.DeploymentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteDeploymentRecord_ViaStubAttachPath_RemovesEntity()
|
||||
{
|
||||
var instance = await SeedInstanceAsync();
|
||||
var record = new DeploymentRecord("d-001", "admin") { InstanceId = instance.Id, DeployedAt = DateTimeOffset.UtcNow };
|
||||
await _repository.AddDeploymentRecordAsync(record);
|
||||
await _repository.SaveChangesAsync();
|
||||
var id = record.Id;
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
await _repository.DeleteDeploymentRecordAsync(id);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
Assert.Null(await _repository.GetDeploymentRecordByIdAsync(id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteInstance_RemovesRestrictFkDeploymentRecordsFirst()
|
||||
{
|
||||
// DeploymentRecord has a Restrict FK to Instance; DeleteInstanceAsync must remove
|
||||
// the dependent deployment records explicitly or the delete would fail.
|
||||
var instance = await SeedInstanceAsync();
|
||||
await _repository.AddDeploymentRecordAsync(
|
||||
new DeploymentRecord("d-001", "admin") { InstanceId = instance.Id, DeployedAt = DateTimeOffset.UtcNow });
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
await _repository.DeleteInstanceAsync(instance.Id);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
Assert.Null(await _repository.GetInstanceByIdAsync(instance.Id));
|
||||
Assert.Empty(await _repository.GetDeploymentsByInstanceIdAsync(instance.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullContext_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new DeploymentManagerRepository(null!));
|
||||
}
|
||||
}
|
||||
|
||||
public class InstanceLocatorTests : IDisposable
|
||||
{
|
||||
private readonly ScadaLinkDbContext _context;
|
||||
private readonly InstanceLocator _locator;
|
||||
|
||||
public InstanceLocatorTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
_locator = new InstanceLocator(_context);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSiteIdForInstance_WhenFound_ReturnsSiteIdentifier()
|
||||
{
|
||||
var site = new Site("Site1", "SITE-001");
|
||||
var template = new Template("T1");
|
||||
_context.Sites.Add(site);
|
||||
_context.Templates.Add(template);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_context.Instances.Add(new Instance("Pump1") { SiteId = site.Id, TemplateId = template.Id });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var result = await _locator.GetSiteIdForInstanceAsync("Pump1");
|
||||
Assert.Equal("SITE-001", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSiteIdForInstance_WhenInstanceNotFound_ReturnsNull()
|
||||
{
|
||||
var result = await _locator.GetSiteIdForInstanceAsync("DoesNotExist");
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullContext_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new InstanceLocator(null!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
using ScadaLink.ConfigurationDatabase.Repositories;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Tests;
|
||||
|
||||
public class SchemaConfigurationTests : IDisposable
|
||||
{
|
||||
private readonly ScadaLinkDbContext _context;
|
||||
|
||||
public SchemaConfigurationTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
// ConfigurationDatabase-006: 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).
|
||||
|
||||
[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());
|
||||
}
|
||||
|
||||
[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());
|
||||
}
|
||||
}
|
||||
|
||||
public class SplitQueryBehaviourTests : IDisposable
|
||||
{
|
||||
private readonly ScadaLinkDbContext _context;
|
||||
private readonly TemplateEngineRepository _repository;
|
||||
|
||||
public SplitQueryBehaviourTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
_repository = new TemplateEngineRepository(_context);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
// ConfigurationDatabase-009: 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.
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user