From a55502254eeedf48db6a5d03afb161ab19d284cc Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 17 May 2026 00:02:45 -0400 Subject: [PATCH] =?UTF-8?q?fix(external-system-gateway):=20resolve=20Exter?= =?UTF-8?q?nalSystemGateway-011=20=E2=80=94=20name-keyed=20repository=20lo?= =?UTF-8?q?okups=20replace=20fetch-all-then-filter=20on=20the=20call=20hot?= =?UTF-8?q?=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ExternalSystemGateway/findings.md | 83 +++++++--- .../Repositories/IExternalSystemRepository.cs | 28 ++++ .../Repositories/ExternalSystemRepository.cs | 18 +++ .../DatabaseGateway.cs | 8 +- .../ExternalSystemClient.cs | 10 +- .../SiteExternalSystemRepository.cs | 74 +++++++++ .../RepositoryCoverageTests.cs | 88 +++++++++++ .../DatabaseGatewayTests.cs | 32 ++-- .../ExternalSystemClientTests.cs | 143 ++++++------------ .../Repositories/SiteRepositoryTests.cs | 95 ++++++++++++ 10 files changed, 448 insertions(+), 131 deletions(-) diff --git a/code-reviews/ExternalSystemGateway/findings.md b/code-reviews/ExternalSystemGateway/findings.md index f544e2b..5cb029e 100644 --- a/code-reviews/ExternalSystemGateway/findings.md +++ b/code-reviews/ExternalSystemGateway/findings.md @@ -8,7 +8,7 @@ | Last reviewed | 2026-05-16 | | Reviewer | claude-agent | | Commit reviewed | `9c60592` | -| Open findings | 1 | +| Open findings | 0 | ## Summary @@ -533,7 +533,7 @@ exception propagates; it was verified to fail before the `try/catch` was added. |--|--| | Severity | Low | | Category | Performance & resource management | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:360-374`, `src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs:169-176` | **Description** @@ -554,26 +554,67 @@ rather than fetch-all-then-filter. **Resolution** -2026-05-16 — **Root cause confirmed, but left Open: no correct fix is possible within -this module's edit scope.** `ResolveSystemAndMethodAsync` -(`ExternalSystemClient.cs:360`) does call `GetAllExternalSystemsAsync()` followed by -`GetMethodsByExternalSystemIdAsync()` and filters in memory, and -`ResolveConnectionAsync` (`DatabaseGateway.cs:169`) does `GetAllDatabaseConnectionsAsync()` -then filters — fetch-all-then-filter on every hot-path call, as described. +Resolved 2026-05-16 (commit ``). A cross-module change was explicitly +authorized, so the **name-keyed repository lookup** recommendation was applied — the +cleaner of the two options, and one that avoids the staleness hazard a +deployment-invalidated cache would introduce. -Both recommended fixes require changes outside `src/ScadaLink.ExternalSystemGateway`: -(a) a **name-keyed repository lookup** (e.g. `GetExternalSystemByNameAsync`) means adding -methods to `IExternalSystemRepository` in `ScadaLink.Commons` and implementing them in -`ScadaLink.ConfigurationDatabase` / `ScadaLink.SiteRuntime`; (b) an **in-memory cache -invalidated on artifact deployment** requires subscribing to a deployment-applied event -owned by `ScadaLink.SiteRuntime` / `ScadaLink.DeploymentManager`. A purely module-local -cache with a time-based TTL was rejected as a fix: definitions only change on deployment -and must reflect a deployment promptly, so a TTL would either be too short to help the -hot path or long enough to serve stale definitions after a redeploy — trading a -correctness hazard for a performance gain on a Low-severity issue. **Tracked follow-up:** -add a name-keyed lookup to `IExternalSystemRepository` (Commons) and have the gateway use -it, or add a deployment-invalidated definition cache wired from SiteRuntime. No source -change was made in this module. +Three name-keyed methods were added to `IExternalSystemRepository` +(`ScadaLink.Commons`): `GetExternalSystemByNameAsync(name)`, +`GetMethodByNameAsync(externalSystemId, methodName)` and +`GetDatabaseConnectionByNameAsync(name)`. The connection lookup belongs on the same +interface because database connection definitions are already part of +`IExternalSystemRepository` (alongside `GetAllDatabaseConnectionsAsync` / +`GetDatabaseConnectionByIdAsync`), so the existing repository organization was +followed rather than introducing a new interface. + +Both implementers of the interface were updated: + +- `ScadaLink.ConfigurationDatabase.ExternalSystemRepository` — all three are genuine + server-side keyed queries (`FirstOrDefaultAsync(x => x.Name == name)`, the + method lookup additionally scoped by `ExternalSystemDefinitionId`), matching the + existing `GetMethodByNameAsync` / `GetListByNameAsync` / `GetSharedScriptByNameAsync` + convention in the other Central repositories. +- `ScadaLink.SiteRuntime.SiteExternalSystemRepository` — `GetExternalSystemByNameAsync` + and `GetDatabaseConnectionByNameAsync` are genuine single-row indexed SQLite queries + (`WHERE name = @name`; both tables have `name` as the PRIMARY KEY). + `GetMethodByNameAsync` resolves the named method from the parent system's + `method_definitions` JSON column; this still requires resolving the parent system + (one id→name scan), but the gateway's new call sequence performs **one** scan instead + of the previous **two** (`GetAllExternalSystemsAsync` + `GetMethodsByExternalSystemIdAsync`, + which itself scanned), so the site path is strictly better than before — noted as + the one place a residual scan remains, bounded by the deployed system count. + +`ExternalSystemClient.ResolveSystemAndMethodAsync` and +`DatabaseGateway.ResolveConnectionAsync` were rewritten to call the keyed lookups; the +`GetAllExternalSystemsAsync` / `GetMethodsByExternalSystemIdAsync` / +`GetAllDatabaseConnectionsAsync` + in-memory `FirstOrDefault` filtering is gone from +both hot paths. + +Regression tests (TDD — written first, verified failing/not-compiling before the +implementation, then confirmed green; one was additionally verified to fail when the +keyed query is deliberately broken): + +- ConfigurationDatabase (`RepositoryCoverageTests`): + `GetExternalSystemByName_ReturnsMatchingRow`, + `GetExternalSystemByName_MissingName_ReturnsNull`, + `GetMethodByName_ReturnsMethodScopedToParentSystem`, + `GetMethodByName_MissingMethod_ReturnsNull`, + `GetDatabaseConnectionByName_ReturnsMatchingRow`, + `GetDatabaseConnectionByName_MissingName_ReturnsNull`. +- SiteRuntime (`SiteRepositoryTests`): + `ExternalSystemRepository_GetByName_ReturnsMatchingDefinition`, + `ExternalSystemRepository_GetByName_MissingName_ReturnsNull`, + `ExternalSystemRepository_GetMethodByName_ResolvesScopedToSystem`, + `DatabaseConnectionRepository_GetByName_ReturnsMatchingDefinition`. +- ExternalSystemGateway: the existing `ExternalSystemClientTests` / + `DatabaseGatewayTests` resolution stubs were migrated to the keyed methods (via + `StubResolution` / `StubConnection` helpers), so the full gateway suite now exercises + and protects the keyed-lookup resolution path. + +`dotnet build ScadaLink.slnx` is clean; `ScadaLink.ExternalSystemGateway.Tests` (54), +`ScadaLink.ConfigurationDatabase.Tests` (106), `ScadaLink.SiteRuntime.Tests` (196) and +`ScadaLink.Commons.Tests` (226) all pass. ### ExternalSystemGateway-012 — Permanent-failure logging requirement is not met; `_logger` is injected but unused diff --git a/src/ScadaLink.Commons/Interfaces/Repositories/IExternalSystemRepository.cs b/src/ScadaLink.Commons/Interfaces/Repositories/IExternalSystemRepository.cs index 0410030..6c4d6b9 100644 --- a/src/ScadaLink.Commons/Interfaces/Repositories/IExternalSystemRepository.cs +++ b/src/ScadaLink.Commons/Interfaces/Repositories/IExternalSystemRepository.cs @@ -6,6 +6,15 @@ public interface IExternalSystemRepository { // ExternalSystemDefinition Task GetExternalSystemByIdAsync(int id, CancellationToken cancellationToken = default); + + /// + /// Returns the external system with the given name, or null if no such + /// system exists. A name-keyed lookup so hot-path resolution (e.g. a script's + /// ExternalSystem.Call()) does not have to fetch every system and filter + /// in memory on each call (ExternalSystemGateway-011). + /// + Task GetExternalSystemByNameAsync(string name, CancellationToken cancellationToken = default); + Task> GetAllExternalSystemsAsync(CancellationToken cancellationToken = default); Task AddExternalSystemAsync(ExternalSystemDefinition definition, CancellationToken cancellationToken = default); Task UpdateExternalSystemAsync(ExternalSystemDefinition definition, CancellationToken cancellationToken = default); @@ -13,6 +22,15 @@ public interface IExternalSystemRepository // ExternalSystemMethod Task GetExternalSystemMethodByIdAsync(int id, CancellationToken cancellationToken = default); + + /// + /// Returns the method with the given name belonging to the given external system, + /// or null if no such method exists. A name-keyed lookup so hot-path + /// resolution does not have to fetch every method of the system and filter in + /// memory on each call (ExternalSystemGateway-011). + /// + Task GetMethodByNameAsync(int externalSystemId, string methodName, CancellationToken cancellationToken = default); + Task> GetMethodsByExternalSystemIdAsync(int externalSystemId, CancellationToken cancellationToken = default); Task AddExternalSystemMethodAsync(ExternalSystemMethod method, CancellationToken cancellationToken = default); Task UpdateExternalSystemMethodAsync(ExternalSystemMethod method, CancellationToken cancellationToken = default); @@ -20,6 +38,16 @@ public interface IExternalSystemRepository // DatabaseConnectionDefinition Task GetDatabaseConnectionByIdAsync(int id, CancellationToken cancellationToken = default); + + /// + /// Returns the database connection definition with the given name, or null + /// if no such connection exists. A name-keyed lookup so hot-path resolution (e.g. + /// a script's Database.Connection() / Database.CachedWrite()) does + /// not have to fetch every connection and filter in memory on each call + /// (ExternalSystemGateway-011). + /// + Task GetDatabaseConnectionByNameAsync(string name, CancellationToken cancellationToken = default); + Task> GetAllDatabaseConnectionsAsync(CancellationToken cancellationToken = default); Task AddDatabaseConnectionAsync(DatabaseConnectionDefinition definition, CancellationToken cancellationToken = default); Task UpdateDatabaseConnectionAsync(DatabaseConnectionDefinition definition, CancellationToken cancellationToken = default); diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/ExternalSystemRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/ExternalSystemRepository.cs index 746d419..71d28c3 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/ExternalSystemRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/ExternalSystemRepository.cs @@ -16,6 +16,12 @@ public class ExternalSystemRepository : IExternalSystemRepository public async Task GetExternalSystemByIdAsync(int id, CancellationToken cancellationToken = default) => await _context.Set().FindAsync(new object[] { id }, cancellationToken); + // ExternalSystemGateway-011: genuine name-keyed query (server-side WHERE) so the + // gateway's hot-path resolution does not fetch every system and filter in memory. + public async Task GetExternalSystemByNameAsync(string name, CancellationToken cancellationToken = default) + => await _context.Set() + .FirstOrDefaultAsync(s => s.Name == name, cancellationToken); + public async Task> GetAllExternalSystemsAsync(CancellationToken cancellationToken = default) => await _context.Set().ToListAsync(cancellationToken); @@ -34,6 +40,13 @@ public class ExternalSystemRepository : IExternalSystemRepository public async Task GetExternalSystemMethodByIdAsync(int id, CancellationToken cancellationToken = default) => await _context.Set().FindAsync(new object[] { id }, cancellationToken); + // ExternalSystemGateway-011: genuine name-keyed query scoped to the parent system. + public async Task GetMethodByNameAsync(int externalSystemId, string methodName, CancellationToken cancellationToken = default) + => await _context.Set() + .FirstOrDefaultAsync( + m => m.ExternalSystemDefinitionId == externalSystemId && m.Name == methodName, + cancellationToken); + public async Task> GetMethodsByExternalSystemIdAsync(int externalSystemId, CancellationToken cancellationToken = default) => await _context.Set().Where(m => m.ExternalSystemDefinitionId == externalSystemId).ToListAsync(cancellationToken); @@ -52,6 +65,11 @@ public class ExternalSystemRepository : IExternalSystemRepository public async Task GetDatabaseConnectionByIdAsync(int id, CancellationToken cancellationToken = default) => await _context.Set().FindAsync(new object[] { id }, cancellationToken); + // ExternalSystemGateway-011: genuine name-keyed query (server-side WHERE). + public async Task GetDatabaseConnectionByNameAsync(string name, CancellationToken cancellationToken = default) + => await _context.Set() + .FirstOrDefaultAsync(c => c.Name == name, cancellationToken); + public async Task> GetAllDatabaseConnectionsAsync(CancellationToken cancellationToken = default) => await _context.Set().ToListAsync(cancellationToken); diff --git a/src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs b/src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs index e88d0f5..3693ab2 100644 --- a/src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs +++ b/src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs @@ -170,8 +170,10 @@ public class DatabaseGateway : IDatabaseGateway string connectionName, CancellationToken cancellationToken) { - var connections = await _repository.GetAllDatabaseConnectionsAsync(cancellationToken); - return connections.FirstOrDefault(c => - c.Name.Equals(connectionName, StringComparison.OrdinalIgnoreCase)); + // ExternalSystemGateway-011: name-keyed repository lookup instead of + // fetch-all-then-filter — connection definitions are resolved on every + // cached write / connection request, so the repository performs an indexed + // query rather than loading every connection into memory. + return await _repository.GetDatabaseConnectionByNameAsync(connectionName, cancellationToken); } } diff --git a/src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs b/src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs index 3bf2ca4..47cddba 100644 --- a/src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs +++ b/src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs @@ -373,13 +373,15 @@ public class ExternalSystemClient : IExternalSystemClient string methodName, CancellationToken cancellationToken) { - var systems = await _repository.GetAllExternalSystemsAsync(cancellationToken); - var system = systems.FirstOrDefault(s => s.Name.Equals(systemName, StringComparison.OrdinalIgnoreCase)); + // ExternalSystemGateway-011: name-keyed repository lookups instead of + // fetch-all-then-filter — definitions are resolved on every hot-path call + // (a script's ExternalSystem.Call()), so the repository performs an indexed + // query rather than loading every system / every method into memory. + var system = await _repository.GetExternalSystemByNameAsync(systemName, cancellationToken); if (system == null) return (null, null); - var methods = await _repository.GetMethodsByExternalSystemIdAsync(system.Id, cancellationToken); - var method = methods.FirstOrDefault(m => m.Name.Equals(methodName, StringComparison.OrdinalIgnoreCase)); + var method = await _repository.GetMethodByNameAsync(system.Id, methodName, cancellationToken); return (system, method); } diff --git a/src/ScadaLink.SiteRuntime/Repositories/SiteExternalSystemRepository.cs b/src/ScadaLink.SiteRuntime/Repositories/SiteExternalSystemRepository.cs index 838238d..2432641 100644 --- a/src/ScadaLink.SiteRuntime/Repositories/SiteExternalSystemRepository.cs +++ b/src/ScadaLink.SiteRuntime/Repositories/SiteExternalSystemRepository.cs @@ -53,6 +53,31 @@ public class SiteExternalSystemRepository : IExternalSystemRepository return all.FirstOrDefault(e => e.Id == id); } + /// + /// ExternalSystemGateway-011: genuine name-keyed lookup. The external_systems + /// table has name as its PRIMARY KEY, so this is a single indexed-row fetch + /// rather than the previous fetch-all-then-filter. + /// + public async Task GetExternalSystemByNameAsync( + string name, CancellationToken cancellationToken = default) + { + await using var connection = CreateConnection(); + await connection.OpenAsync(cancellationToken); + + await using var command = connection.CreateCommand(); + command.CommandText = @" + SELECT name, endpoint_url, auth_type, auth_configuration + FROM external_systems + WHERE name = @name"; + command.Parameters.AddWithValue("@name", name); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + return null; + + return MapExternalSystem(reader); + } + // ── ExternalSystemMethod (read) ── public async Task> GetMethodsByExternalSystemIdAsync( @@ -80,6 +105,23 @@ public class SiteExternalSystemRepository : IExternalSystemRepository return ParseMethodDefinitions(json, externalSystemId); } + /// + /// ExternalSystemGateway-011: name-keyed method lookup. The site store keeps a + /// system's methods in a single JSON column, so this fetches that one row and + /// matches the named method in memory. The parent system is resolved via + /// , which already performs a + /// single keyed read of the method_definitions column — this is no more + /// I/O than the gateway's previous fetch-all-methods-then-filter call, and on + /// the SQL-backed Central repository it is a genuine indexed query. + /// + public async Task GetMethodByNameAsync( + int externalSystemId, string methodName, CancellationToken cancellationToken = default) + { + var methods = await GetMethodsByExternalSystemIdAsync(externalSystemId, cancellationToken); + return methods.FirstOrDefault( + m => m.Name.Equals(methodName, StringComparison.OrdinalIgnoreCase)); + } + public async Task GetExternalSystemMethodByIdAsync( int id, CancellationToken cancellationToken = default) { @@ -134,6 +176,38 @@ public class SiteExternalSystemRepository : IExternalSystemRepository return all.FirstOrDefault(d => d.Id == id); } + /// + /// ExternalSystemGateway-011: genuine name-keyed lookup. The + /// database_connections table has name as its PRIMARY KEY, so this + /// is a single indexed-row fetch rather than fetch-all-then-filter. + /// + public async Task GetDatabaseConnectionByNameAsync( + string name, CancellationToken cancellationToken = default) + { + await using var connection = CreateConnection(); + await connection.OpenAsync(cancellationToken); + + await using var command = connection.CreateCommand(); + command.CommandText = @" + SELECT name, connection_string, max_retries, retry_delay_ms + FROM database_connections + WHERE name = @name"; + command.Parameters.AddWithValue("@name", name); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + return null; + + return new DatabaseConnectionDefinition( + name: reader.GetString(0), + connectionString: reader.GetString(1)) + { + Id = GenerateSyntheticId(reader.GetString(0)), + MaxRetries = reader.GetInt32(2), + RetryDelay = TimeSpan.FromMilliseconds(reader.GetInt64(3)) + }; + } + // ── Write operations (not supported on site) ── public Task AddExternalSystemAsync(ExternalSystemDefinition definition, CancellationToken cancellationToken = default) diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs index bf4370d..3e954ce 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs @@ -76,6 +76,94 @@ public class ExternalSystemRepositoryTests : IDisposable { Assert.Throws(() => new ExternalSystemRepository(null!)); } + + // ── ExternalSystemGateway-011: name-keyed repository lookups ── + + [Fact] + public async Task GetExternalSystemByName_ReturnsMatchingRow() + { + await _repository.AddExternalSystemAsync( + new ExternalSystemDefinition("Alpha", "https://alpha.test", "ApiKey")); + await _repository.AddExternalSystemAsync( + new ExternalSystemDefinition("Beta", "https://beta.test", "Basic")); + await _repository.SaveChangesAsync(); + + var loaded = await _repository.GetExternalSystemByNameAsync("Beta"); + + Assert.NotNull(loaded); + Assert.Equal("Beta", loaded!.Name); + Assert.Equal("https://beta.test", loaded.EndpointUrl); + } + + [Fact] + public async Task GetExternalSystemByName_MissingName_ReturnsNull() + { + await _repository.AddExternalSystemAsync( + new ExternalSystemDefinition("Alpha", "https://alpha.test", "ApiKey")); + await _repository.SaveChangesAsync(); + + Assert.Null(await _repository.GetExternalSystemByNameAsync("DoesNotExist")); + } + + [Fact] + public async Task GetMethodByName_ReturnsMethodScopedToParentSystem() + { + var sysA = new ExternalSystemDefinition("SysA", "https://a.test", "ApiKey"); + var sysB = new ExternalSystemDefinition("SysB", "https://b.test", "ApiKey"); + await _repository.AddExternalSystemAsync(sysA); + await _repository.AddExternalSystemAsync(sysB); + await _repository.SaveChangesAsync(); + + // Same method name on two different systems — the lookup must be scoped. + await _repository.AddExternalSystemMethodAsync( + new ExternalSystemMethod("getData", "GET", "/a") { ExternalSystemDefinitionId = sysA.Id }); + await _repository.AddExternalSystemMethodAsync( + new ExternalSystemMethod("getData", "POST", "/b") { ExternalSystemDefinitionId = sysB.Id }); + await _repository.SaveChangesAsync(); + + var method = await _repository.GetMethodByNameAsync(sysB.Id, "getData"); + + Assert.NotNull(method); + Assert.Equal(sysB.Id, method!.ExternalSystemDefinitionId); + Assert.Equal("POST", method.HttpMethod); + Assert.Equal("/b", method.Path); + } + + [Fact] + public async Task GetMethodByName_MissingMethod_ReturnsNull() + { + var sys = new ExternalSystemDefinition("SysA", "https://a.test", "ApiKey"); + await _repository.AddExternalSystemAsync(sys); + await _repository.SaveChangesAsync(); + + Assert.Null(await _repository.GetMethodByNameAsync(sys.Id, "noSuchMethod")); + } + + [Fact] + public async Task GetDatabaseConnectionByName_ReturnsMatchingRow() + { + await _repository.AddDatabaseConnectionAsync( + new DatabaseConnectionDefinition("Plant", "Server=plant;Database=p;")); + await _repository.AddDatabaseConnectionAsync( + new DatabaseConnectionDefinition("Historian", "Server=hist;Database=h;")); + await _repository.SaveChangesAsync(); + + var loaded = await _repository.GetDatabaseConnectionByNameAsync("Historian"); + + Assert.NotNull(loaded); + Assert.Equal("Historian", loaded!.Name); + Assert.Equal("Server=hist;Database=h;", loaded.ConnectionString); + } + + [Fact] + public async Task GetDatabaseConnectionByName_MissingName_ReturnsNull() + { + await _repository.AddDatabaseConnectionAsync( + new DatabaseConnectionDefinition("Plant", "Server=plant;Database=p;")); + await _repository.SaveChangesAsync(); + + Assert.Null(await _repository.GetDatabaseConnectionByNameAsync("DoesNotExist")); + } } public class NotificationRepositoryTests : IDisposable diff --git a/tests/ScadaLink.ExternalSystemGateway.Tests/DatabaseGatewayTests.cs b/tests/ScadaLink.ExternalSystemGateway.Tests/DatabaseGatewayTests.cs index b5175be..c94ec72 100644 --- a/tests/ScadaLink.ExternalSystemGateway.Tests/DatabaseGatewayTests.cs +++ b/tests/ScadaLink.ExternalSystemGateway.Tests/DatabaseGatewayTests.cs @@ -14,10 +14,25 @@ public class DatabaseGatewayTests { private readonly IExternalSystemRepository _repository = Substitute.For(); + /// + /// Configures the repository substitute for the name-keyed connection-resolution + /// path used by DatabaseGateway (ExternalSystemGateway-011). A null + /// connection models a "not found" — the substitute returns null by default, + /// so no stub is needed for the absent entity. + /// + private void StubConnection(DatabaseConnectionDefinition? connection) + { + if (connection != null) + { + _repository.GetDatabaseConnectionByNameAsync(connection.Name, Arg.Any()) + .Returns(connection); + } + } + [Fact] public async Task GetConnection_NotFound_Throws() { - _repository.GetAllDatabaseConnectionsAsync().Returns(new List()); + StubConnection(connection: null); var gateway = new DatabaseGateway( _repository, @@ -31,8 +46,7 @@ public class DatabaseGatewayTests public async Task CachedWrite_NoStoreAndForward_Throws() { var conn = new DatabaseConnectionDefinition("testDb", "Server=localhost;Database=test") { Id = 1 }; - _repository.GetAllDatabaseConnectionsAsync() - .Returns(new List { conn }); + StubConnection(conn); var gateway = new DatabaseGateway( _repository, @@ -46,7 +60,7 @@ public class DatabaseGatewayTests [Fact] public async Task CachedWrite_ConnectionNotFound_Throws() { - _repository.GetAllDatabaseConnectionsAsync().Returns(new List()); + StubConnection(connection: null); var gateway = new DatabaseGateway( _repository, @@ -67,8 +81,7 @@ public class DatabaseGatewayTests MaxRetries = 5, RetryDelay = TimeSpan.FromSeconds(12), }; - _repository.GetAllDatabaseConnectionsAsync(Arg.Any()) - .Returns(new List { conn }); + StubConnection(conn); var dbName = $"EsgCachedWrite_{Guid.NewGuid():N}"; var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared"; @@ -108,8 +121,7 @@ public class DatabaseGatewayTests MaxRetries = 0, RetryDelay = TimeSpan.FromSeconds(3), }; - _repository.GetAllDatabaseConnectionsAsync(Arg.Any()) - .Returns(new List { conn }); + StubConnection(conn); var dbName = $"EsgCachedWriteZero_{Guid.NewGuid():N}"; var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared"; @@ -153,7 +165,7 @@ public class DatabaseGatewayTests [Fact] public async Task DeliverBuffered_ConnectionNoLongerExists_ReturnsFalseSoMessageParks() { - _repository.GetAllDatabaseConnectionsAsync().Returns(new List()); + StubConnection(connection: null); var gateway = new DatabaseGateway(_repository, NullLogger.Instance); var message = new ScadaLink.StoreAndForward.StoreAndForwardMessage @@ -176,7 +188,7 @@ public class DatabaseGatewayTests public async Task GetConnection_OpenFails_DisposesConnectionBeforeRethrowing() { var conn = new DatabaseConnectionDefinition("testDb", "Server=localhost;Database=test") { Id = 1 }; - _repository.GetAllDatabaseConnectionsAsync().Returns(new List { conn }); + StubConnection(conn); var fake = new ThrowingDbConnection(); var gateway = new ConnectionFactoryStubGateway(_repository, fake); diff --git a/tests/ScadaLink.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs b/tests/ScadaLink.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs index 01bb22b..f40ee82 100644 --- a/tests/ScadaLink.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs +++ b/tests/ScadaLink.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs @@ -18,10 +18,31 @@ public class ExternalSystemClientTests private readonly IExternalSystemRepository _repository = Substitute.For(); private readonly IHttpClientFactory _httpClientFactory = Substitute.For(); + /// + /// Configures the repository substitute for the name-keyed resolution path used by + /// ExternalSystemClient (ExternalSystemGateway-011). A null system or + /// method models a "not found" — the substitute returns null by default, so + /// no stub is needed for the absent entity. + /// + private void StubResolution(ExternalSystemDefinition? system, ExternalSystemMethod? method) + { + if (system != null) + { + _repository.GetExternalSystemByNameAsync(system.Name, Arg.Any()) + .Returns(system); + } + + if (system != null && method != null) + { + _repository.GetMethodByNameAsync(system.Id, method.Name, Arg.Any()) + .Returns(method); + } + } + [Fact] public async Task Call_SystemNotFound_ReturnsError() { - _repository.GetAllExternalSystemsAsync().Returns(new List()); + StubResolution(system: null, method: null); var client = new ExternalSystemClient( _httpClientFactory, _repository, @@ -37,8 +58,7 @@ public class ExternalSystemClientTests public async Task Call_MethodNotFound_ReturnsError() { var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 }; - _repository.GetAllExternalSystemsAsync().Returns(new List { system }); - _repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List()); + StubResolution(system, method: null); var client = new ExternalSystemClient( _httpClientFactory, _repository, @@ -56,8 +76,7 @@ public class ExternalSystemClientTests var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 }; var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 }; - _repository.GetAllExternalSystemsAsync().Returns(new List { system }); - _repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List { method }); + StubResolution(system, method); var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{\"result\": 42}"); var httpClient = new HttpClient(handler); @@ -79,8 +98,7 @@ public class ExternalSystemClientTests var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 }; var method = new ExternalSystemMethod("failMethod", "POST", "/fail") { Id = 1, ExternalSystemDefinitionId = 1 }; - _repository.GetAllExternalSystemsAsync().Returns(new List { system }); - _repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List { method }); + StubResolution(system, method); var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "server error"); var httpClient = new HttpClient(handler); @@ -102,8 +120,7 @@ public class ExternalSystemClientTests var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 }; var method = new ExternalSystemMethod("badMethod", "POST", "/bad") { Id = 1, ExternalSystemDefinitionId = 1 }; - _repository.GetAllExternalSystemsAsync().Returns(new List { system }); - _repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List { method }); + StubResolution(system, method); var handler = new MockHttpMessageHandler(HttpStatusCode.BadRequest, "bad request"); var httpClient = new HttpClient(handler); @@ -122,7 +139,7 @@ public class ExternalSystemClientTests [Fact] public async Task CachedCall_SystemNotFound_ReturnsError() { - _repository.GetAllExternalSystemsAsync().Returns(new List()); + StubResolution(system: null, method: null); var client = new ExternalSystemClient( _httpClientFactory, _repository, @@ -140,8 +157,7 @@ public class ExternalSystemClientTests var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 }; var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 }; - _repository.GetAllExternalSystemsAsync().Returns(new List { system }); - _repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List { method }); + StubResolution(system, method); var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{\"ok\": true}"); var httpClient = new HttpClient(handler); @@ -175,8 +191,7 @@ public class ExternalSystemClientTests { var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 }; var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 }; - _repository.GetAllExternalSystemsAsync().Returns(new List { system }); - _repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List { method }); + StubResolution(system, method); var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.OK, "{\"ok\":true}")); _httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient); @@ -192,7 +207,7 @@ public class ExternalSystemClientTests [Fact] public async Task DeliverBuffered_SystemNoLongerExists_ReturnsFalseSoMessageParks() { - _repository.GetAllExternalSystemsAsync().Returns(new List()); + StubResolution(system: null, method: null); var client = new ExternalSystemClient( _httpClientFactory, _repository, NullLogger.Instance); @@ -207,8 +222,7 @@ public class ExternalSystemClientTests { var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 }; var method = new ExternalSystemMethod("failMethod", "POST", "/fail") { Id = 1, ExternalSystemDefinitionId = 1 }; - _repository.GetAllExternalSystemsAsync().Returns(new List { system }); - _repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List { method }); + StubResolution(system, method); var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom")); _httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient); @@ -227,10 +241,7 @@ public class ExternalSystemClientTests { var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 }; var method = new ExternalSystemMethod("postData", "POST", "/post") { Id = 1, ExternalSystemDefinitionId = 1 }; - _repository.GetAllExternalSystemsAsync(Arg.Any()) - .Returns(new List { system }); - _repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any()) - .Returns(new List { method }); + StubResolution(system, method); // The HTTP layer always fails transiently (500). var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom")); @@ -275,10 +286,7 @@ public class ExternalSystemClientTests { var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 }; var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 }; - _repository.GetAllExternalSystemsAsync(Arg.Any()) - .Returns(new List { system }); - _repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any()) - .Returns(new List { method }); + StubResolution(system, method); // Handler that hangs far longer than the configured timeout and the test budget. var httpClient = new HttpClient(new HangingHttpMessageHandler(TimeSpan.FromMinutes(10))); @@ -308,10 +316,7 @@ public class ExternalSystemClientTests { var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 }; var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 }; - _repository.GetAllExternalSystemsAsync(Arg.Any()) - .Returns(new List { system }); - _repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any()) - .Returns(new List { method }); + StubResolution(system, method); var httpClient = new HttpClient(new HangingHttpMessageHandler(TimeSpan.FromMinutes(10))); _httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient); @@ -342,10 +347,7 @@ public class ExternalSystemClientTests RetryDelay = TimeSpan.FromSeconds(42), }; var method = new ExternalSystemMethod("postData", "POST", "/post") { Id = 1, ExternalSystemDefinitionId = 1 }; - _repository.GetAllExternalSystemsAsync(Arg.Any()) - .Returns(new List { system }); - _repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any()) - .Returns(new List { method }); + StubResolution(system, method); var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom")); _httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient); @@ -404,10 +406,7 @@ public class ExternalSystemClientTests RetryDelay = TimeSpan.FromSeconds(5), }; var method = new ExternalSystemMethod("postData", "POST", "/post") { Id = 1, ExternalSystemDefinitionId = 1 }; - _repository.GetAllExternalSystemsAsync(Arg.Any()) - .Returns(new List { system }); - _repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any()) - .Returns(new List { method }); + StubResolution(system, method); var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom")); _httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient); @@ -443,10 +442,7 @@ public class ExternalSystemClientTests { var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 }; var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 }; - _repository.GetAllExternalSystemsAsync(Arg.Any()) - .Returns(new List { system }); - _repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any()) - .Returns(new List { method }); + StubResolution(system, method); var handler = new DisposalTrackingHandler(HttpStatusCode.OK, "{\"ok\":true}"); var httpClient = new HttpClient(handler); @@ -466,10 +462,7 @@ public class ExternalSystemClientTests { var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 }; var method = new ExternalSystemMethod("badMethod", "POST", "/bad") { Id = 1, ExternalSystemDefinitionId = 1 }; - _repository.GetAllExternalSystemsAsync(Arg.Any()) - .Returns(new List { system }); - _repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any()) - .Returns(new List { method }); + StubResolution(system, method); var handler = new DisposalTrackingHandler(HttpStatusCode.BadRequest, "bad request"); var httpClient = new HttpClient(handler); @@ -491,10 +484,7 @@ public class ExternalSystemClientTests { var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com/api", "none") { Id = 1 }; var method = new ExternalSystemMethod("root", "GET", "") { Id = 1, ExternalSystemDefinitionId = 1 }; - _repository.GetAllExternalSystemsAsync(Arg.Any()) - .Returns(new List { system }); - _repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any()) - .Returns(new List { method }); + StubResolution(system, method); var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}"); var httpClient = new HttpClient(handler); @@ -513,10 +503,7 @@ public class ExternalSystemClientTests { var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com/api", "none") { Id = 1 }; var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 }; - _repository.GetAllExternalSystemsAsync(Arg.Any()) - .Returns(new List { system }); - _repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any()) - .Returns(new List { method }); + StubResolution(system, method); var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}"); var httpClient = new HttpClient(handler); @@ -537,10 +524,7 @@ public class ExternalSystemClientTests { var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 }; var method = new ExternalSystemMethod("badMethod", "POST", "/bad") { Id = 1, ExternalSystemDefinitionId = 1 }; - _repository.GetAllExternalSystemsAsync(Arg.Any()) - .Returns(new List { system }); - _repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any()) - .Returns(new List { method }); + StubResolution(system, method); var hugeBody = new string('X', 500_000); var handler = new MockHttpMessageHandler(HttpStatusCode.BadRequest, hugeBody); @@ -566,10 +550,7 @@ public class ExternalSystemClientTests { var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 }; var method = new ExternalSystemMethod("postData", "POST", "/post") { Id = 1, ExternalSystemDefinitionId = 1 }; - _repository.GetAllExternalSystemsAsync(Arg.Any()) - .Returns(new List { system }); - _repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any()) - .Returns(new List { method }); + StubResolution(system, method); var httpClient = new HttpClient(new HangingHttpMessageHandler(TimeSpan.FromMinutes(10))); _httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient); @@ -612,10 +593,7 @@ public class ExternalSystemClientTests { var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 }; var method = new ExternalSystemMethod("search", "GET", "/search") { Id = 1, ExternalSystemDefinitionId = 1 }; - _repository.GetAllExternalSystemsAsync(Arg.Any()) - .Returns(new List { system }); - _repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any()) - .Returns(new List { method }); + StubResolution(system, method); var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}"); _httpClientFactory.CreateClient(Arg.Any()).Returns(new HttpClient(handler)); @@ -642,10 +620,7 @@ public class ExternalSystemClientTests { var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 }; var method = new ExternalSystemMethod("create", "POST", "/create") { Id = 1, ExternalSystemDefinitionId = 1 }; - _repository.GetAllExternalSystemsAsync(Arg.Any()) - .Returns(new List { system }); - _repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any()) - .Returns(new List { method }); + StubResolution(system, method); var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}"); _httpClientFactory.CreateClient(Arg.Any()).Returns(new HttpClient(handler)); @@ -668,10 +643,7 @@ public class ExternalSystemClientTests AuthConfiguration = "secret-key-123", }; var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 }; - _repository.GetAllExternalSystemsAsync(Arg.Any()) - .Returns(new List { system }); - _repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any()) - .Returns(new List { method }); + StubResolution(system, method); var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}"); _httpClientFactory.CreateClient(Arg.Any()).Returns(new HttpClient(handler)); @@ -694,10 +666,7 @@ public class ExternalSystemClientTests AuthConfiguration = "Authorization-Token:abc", }; var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 }; - _repository.GetAllExternalSystemsAsync(Arg.Any()) - .Returns(new List { system }); - _repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any()) - .Returns(new List { method }); + StubResolution(system, method); var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}"); _httpClientFactory.CreateClient(Arg.Any()).Returns(new HttpClient(handler)); @@ -720,10 +689,7 @@ public class ExternalSystemClientTests AuthConfiguration = "alice:s3cret", }; var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 }; - _repository.GetAllExternalSystemsAsync(Arg.Any()) - .Returns(new List { system }); - _repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any()) - .Returns(new List { method }); + StubResolution(system, method); var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}"); _httpClientFactory.CreateClient(Arg.Any()).Returns(new HttpClient(handler)); @@ -745,10 +711,7 @@ public class ExternalSystemClientTests { var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 }; var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 }; - _repository.GetAllExternalSystemsAsync(Arg.Any()) - .Returns(new List { system }); - _repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any()) - .Returns(new List { method }); + StubResolution(system, method); // A connection-level failure (e.g. host unreachable) surfaces as HttpRequestException. var handler = new ThrowingHttpMessageHandler(new HttpRequestException("connection refused")); @@ -770,10 +733,7 @@ public class ExternalSystemClientTests { var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 }; var method = new ExternalSystemMethod("badMethod", "POST", "/bad") { Id = 1, ExternalSystemDefinitionId = 1 }; - _repository.GetAllExternalSystemsAsync(Arg.Any()) - .Returns(new List { system }); - _repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any()) - .Returns(new List { method }); + StubResolution(system, method); var handler = new MockHttpMessageHandler(HttpStatusCode.BadRequest, "bad request"); var httpClient = new HttpClient(handler); @@ -795,10 +755,7 @@ public class ExternalSystemClientTests { var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 }; var method = new ExternalSystemMethod("failMethod", "POST", "/fail") { Id = 1, ExternalSystemDefinitionId = 1 }; - _repository.GetAllExternalSystemsAsync(Arg.Any()) - .Returns(new List { system }); - _repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any()) - .Returns(new List { method }); + StubResolution(system, method); var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom"); var httpClient = new HttpClient(handler); diff --git a/tests/ScadaLink.SiteRuntime.Tests/Repositories/SiteRepositoryTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Repositories/SiteRepositoryTests.cs index 72125ef..8dd0235 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Repositories/SiteRepositoryTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Repositories/SiteRepositoryTests.cs @@ -84,6 +84,101 @@ public class SiteRepositoryTests : IDisposable Assert.Equal("StableSystem", found.Name); } + // ── ExternalSystemGateway-011: name-keyed repository lookups ── + + /// + /// ExternalSystemGateway-011: the site repository's name-keyed external-system + /// lookup returns the matching row, and the same synthetic ID as the by-ID path. + /// + [Fact] + public async Task ExternalSystemRepository_GetByName_ReturnsMatchingDefinition() + { + var storage = NewStorage(); + await storage.InitializeAsync(); + await storage.StoreExternalSystemAsync( + "Alpha", "https://alpha.test", "ApiKey", "{\"key\":\"x\"}", null); + await storage.StoreExternalSystemAsync( + "Beta", "https://beta.test", "Basic", null, null); + + var repo = new SiteExternalSystemRepository(storage); + + var found = await repo.GetExternalSystemByNameAsync("Beta"); + Assert.NotNull(found); + Assert.Equal("Beta", found!.Name); + Assert.Equal("https://beta.test", found.EndpointUrl); + + // The by-name path must produce the same synthetic ID as the by-id path. + var byId = await repo.GetExternalSystemByIdAsync(found.Id); + Assert.NotNull(byId); + Assert.Equal("Beta", byId!.Name); + } + + /// + /// ExternalSystemGateway-011: a missing name resolves to null. + /// + [Fact] + public async Task ExternalSystemRepository_GetByName_MissingName_ReturnsNull() + { + var storage = NewStorage(); + await storage.InitializeAsync(); + await storage.StoreExternalSystemAsync( + "Alpha", "https://alpha.test", "ApiKey", null, null); + + var repo = new SiteExternalSystemRepository(storage); + + Assert.Null(await repo.GetExternalSystemByNameAsync("DoesNotExist")); + } + + /// + /// ExternalSystemGateway-011: the site repository's name-keyed method lookup + /// returns the method scoped to its parent system, or null for a miss. + /// + [Fact] + public async Task ExternalSystemRepository_GetMethodByName_ResolvesScopedToSystem() + { + var storage = NewStorage(); + await storage.InitializeAsync(); + var methodDefs = "[{\"Name\":\"getData\",\"HttpMethod\":\"GET\",\"Path\":\"/data\"}]"; + await storage.StoreExternalSystemAsync( + "WeatherApi", "https://api.example.com", "ApiKey", null, methodDefs); + + var repo = new SiteExternalSystemRepository(storage); + var system = await repo.GetExternalSystemByNameAsync("WeatherApi"); + Assert.NotNull(system); + + var method = await repo.GetMethodByNameAsync(system!.Id, "getData"); + Assert.NotNull(method); + Assert.Equal("getData", method!.Name); + Assert.Equal("GET", method.HttpMethod); + + Assert.Null(await repo.GetMethodByNameAsync(system.Id, "noSuchMethod")); + } + + /// + /// ExternalSystemGateway-011: the site repository's name-keyed database-connection + /// lookup returns the matching row, or null for a miss. + /// + [Fact] + public async Task DatabaseConnectionRepository_GetByName_ReturnsMatchingDefinition() + { + var storage = NewStorage(); + await storage.InitializeAsync(); + await storage.StoreDatabaseConnectionAsync( + "Plant", "Server=plant;Database=p;", maxRetries: 3, retryDelay: TimeSpan.FromSeconds(2)); + await storage.StoreDatabaseConnectionAsync( + "Historian", "Server=hist;Database=h;", maxRetries: 0, retryDelay: TimeSpan.FromSeconds(5)); + + var repo = new SiteExternalSystemRepository(storage); + + var found = await repo.GetDatabaseConnectionByNameAsync("Historian"); + Assert.NotNull(found); + Assert.Equal("Historian", found!.Name); + Assert.Equal("Server=hist;Database=h;", found.ConnectionString); + Assert.Equal(0, found.MaxRetries); + + Assert.Null(await repo.GetDatabaseConnectionByNameAsync("DoesNotExist")); + } + /// /// SiteRuntime-007: the same stability guarantee for notification lists. ///