fix(external-system-gateway): resolve ExternalSystemGateway-011 — name-keyed repository lookups replace fetch-all-then-filter on the call hot path
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
| Last reviewed | 2026-05-16 |
|
| Last reviewed | 2026-05-16 |
|
||||||
| Reviewer | claude-agent |
|
| Reviewer | claude-agent |
|
||||||
| Commit reviewed | `9c60592` |
|
| Commit reviewed | `9c60592` |
|
||||||
| Open findings | 1 |
|
| Open findings | 0 |
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -533,7 +533,7 @@ exception propagates; it was verified to fail before the `try/catch` was added.
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Performance & resource management |
|
| Category | Performance & resource management |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:360-374`, `src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs:169-176` |
|
| Location | `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:360-374`, `src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs:169-176` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -554,26 +554,67 @@ rather than fetch-all-then-filter.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
2026-05-16 — **Root cause confirmed, but left Open: no correct fix is possible within
|
Resolved 2026-05-16 (commit `<pending>`). A cross-module change was explicitly
|
||||||
this module's edit scope.** `ResolveSystemAndMethodAsync`
|
authorized, so the **name-keyed repository lookup** recommendation was applied — the
|
||||||
(`ExternalSystemClient.cs:360`) does call `GetAllExternalSystemsAsync()` followed by
|
cleaner of the two options, and one that avoids the staleness hazard a
|
||||||
`GetMethodsByExternalSystemIdAsync()` and filters in memory, and
|
deployment-invalidated cache would introduce.
|
||||||
`ResolveConnectionAsync` (`DatabaseGateway.cs:169`) does `GetAllDatabaseConnectionsAsync()`
|
|
||||||
then filters — fetch-all-then-filter on every hot-path call, as described.
|
|
||||||
|
|
||||||
Both recommended fixes require changes outside `src/ScadaLink.ExternalSystemGateway`:
|
Three name-keyed methods were added to `IExternalSystemRepository`
|
||||||
(a) a **name-keyed repository lookup** (e.g. `GetExternalSystemByNameAsync`) means adding
|
(`ScadaLink.Commons`): `GetExternalSystemByNameAsync(name)`,
|
||||||
methods to `IExternalSystemRepository` in `ScadaLink.Commons` and implementing them in
|
`GetMethodByNameAsync(externalSystemId, methodName)` and
|
||||||
`ScadaLink.ConfigurationDatabase` / `ScadaLink.SiteRuntime`; (b) an **in-memory cache
|
`GetDatabaseConnectionByNameAsync(name)`. The connection lookup belongs on the same
|
||||||
invalidated on artifact deployment** requires subscribing to a deployment-applied event
|
interface because database connection definitions are already part of
|
||||||
owned by `ScadaLink.SiteRuntime` / `ScadaLink.DeploymentManager`. A purely module-local
|
`IExternalSystemRepository` (alongside `GetAllDatabaseConnectionsAsync` /
|
||||||
cache with a time-based TTL was rejected as a fix: definitions only change on deployment
|
`GetDatabaseConnectionByIdAsync`), so the existing repository organization was
|
||||||
and must reflect a deployment promptly, so a TTL would either be too short to help the
|
followed rather than introducing a new interface.
|
||||||
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:**
|
Both implementers of the interface were updated:
|
||||||
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
|
- `ScadaLink.ConfigurationDatabase.ExternalSystemRepository` — all three are genuine
|
||||||
change was made in this module.
|
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
|
### ExternalSystemGateway-012 — Permanent-failure logging requirement is not met; `_logger` is injected but unused
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ public interface IExternalSystemRepository
|
|||||||
{
|
{
|
||||||
// ExternalSystemDefinition
|
// ExternalSystemDefinition
|
||||||
Task<ExternalSystemDefinition?> GetExternalSystemByIdAsync(int id, CancellationToken cancellationToken = default);
|
Task<ExternalSystemDefinition?> GetExternalSystemByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the external system with the given name, or <c>null</c> if no such
|
||||||
|
/// system exists. A name-keyed lookup so hot-path resolution (e.g. a script's
|
||||||
|
/// <c>ExternalSystem.Call()</c>) does not have to fetch every system and filter
|
||||||
|
/// in memory on each call (ExternalSystemGateway-011).
|
||||||
|
/// </summary>
|
||||||
|
Task<ExternalSystemDefinition?> GetExternalSystemByNameAsync(string name, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<IReadOnlyList<ExternalSystemDefinition>> GetAllExternalSystemsAsync(CancellationToken cancellationToken = default);
|
Task<IReadOnlyList<ExternalSystemDefinition>> GetAllExternalSystemsAsync(CancellationToken cancellationToken = default);
|
||||||
Task AddExternalSystemAsync(ExternalSystemDefinition definition, CancellationToken cancellationToken = default);
|
Task AddExternalSystemAsync(ExternalSystemDefinition definition, CancellationToken cancellationToken = default);
|
||||||
Task UpdateExternalSystemAsync(ExternalSystemDefinition definition, CancellationToken cancellationToken = default);
|
Task UpdateExternalSystemAsync(ExternalSystemDefinition definition, CancellationToken cancellationToken = default);
|
||||||
@@ -13,6 +22,15 @@ public interface IExternalSystemRepository
|
|||||||
|
|
||||||
// ExternalSystemMethod
|
// ExternalSystemMethod
|
||||||
Task<ExternalSystemMethod?> GetExternalSystemMethodByIdAsync(int id, CancellationToken cancellationToken = default);
|
Task<ExternalSystemMethod?> GetExternalSystemMethodByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the method with the given name belonging to the given external system,
|
||||||
|
/// or <c>null</c> 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).
|
||||||
|
/// </summary>
|
||||||
|
Task<ExternalSystemMethod?> GetMethodByNameAsync(int externalSystemId, string methodName, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<IReadOnlyList<ExternalSystemMethod>> GetMethodsByExternalSystemIdAsync(int externalSystemId, CancellationToken cancellationToken = default);
|
Task<IReadOnlyList<ExternalSystemMethod>> GetMethodsByExternalSystemIdAsync(int externalSystemId, CancellationToken cancellationToken = default);
|
||||||
Task AddExternalSystemMethodAsync(ExternalSystemMethod method, CancellationToken cancellationToken = default);
|
Task AddExternalSystemMethodAsync(ExternalSystemMethod method, CancellationToken cancellationToken = default);
|
||||||
Task UpdateExternalSystemMethodAsync(ExternalSystemMethod method, CancellationToken cancellationToken = default);
|
Task UpdateExternalSystemMethodAsync(ExternalSystemMethod method, CancellationToken cancellationToken = default);
|
||||||
@@ -20,6 +38,16 @@ public interface IExternalSystemRepository
|
|||||||
|
|
||||||
// DatabaseConnectionDefinition
|
// DatabaseConnectionDefinition
|
||||||
Task<DatabaseConnectionDefinition?> GetDatabaseConnectionByIdAsync(int id, CancellationToken cancellationToken = default);
|
Task<DatabaseConnectionDefinition?> GetDatabaseConnectionByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the database connection definition with the given name, or <c>null</c>
|
||||||
|
/// if no such connection exists. A name-keyed lookup so hot-path resolution (e.g.
|
||||||
|
/// a script's <c>Database.Connection()</c> / <c>Database.CachedWrite()</c>) does
|
||||||
|
/// not have to fetch every connection and filter in memory on each call
|
||||||
|
/// (ExternalSystemGateway-011).
|
||||||
|
/// </summary>
|
||||||
|
Task<DatabaseConnectionDefinition?> GetDatabaseConnectionByNameAsync(string name, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<IReadOnlyList<DatabaseConnectionDefinition>> GetAllDatabaseConnectionsAsync(CancellationToken cancellationToken = default);
|
Task<IReadOnlyList<DatabaseConnectionDefinition>> GetAllDatabaseConnectionsAsync(CancellationToken cancellationToken = default);
|
||||||
Task AddDatabaseConnectionAsync(DatabaseConnectionDefinition definition, CancellationToken cancellationToken = default);
|
Task AddDatabaseConnectionAsync(DatabaseConnectionDefinition definition, CancellationToken cancellationToken = default);
|
||||||
Task UpdateDatabaseConnectionAsync(DatabaseConnectionDefinition definition, CancellationToken cancellationToken = default);
|
Task UpdateDatabaseConnectionAsync(DatabaseConnectionDefinition definition, CancellationToken cancellationToken = default);
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ public class ExternalSystemRepository : IExternalSystemRepository
|
|||||||
public async Task<ExternalSystemDefinition?> GetExternalSystemByIdAsync(int id, CancellationToken cancellationToken = default)
|
public async Task<ExternalSystemDefinition?> GetExternalSystemByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
=> await _context.Set<ExternalSystemDefinition>().FindAsync(new object[] { id }, cancellationToken);
|
=> await _context.Set<ExternalSystemDefinition>().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<ExternalSystemDefinition?> GetExternalSystemByNameAsync(string name, CancellationToken cancellationToken = default)
|
||||||
|
=> await _context.Set<ExternalSystemDefinition>()
|
||||||
|
.FirstOrDefaultAsync(s => s.Name == name, cancellationToken);
|
||||||
|
|
||||||
public async Task<IReadOnlyList<ExternalSystemDefinition>> GetAllExternalSystemsAsync(CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<ExternalSystemDefinition>> GetAllExternalSystemsAsync(CancellationToken cancellationToken = default)
|
||||||
=> await _context.Set<ExternalSystemDefinition>().ToListAsync(cancellationToken);
|
=> await _context.Set<ExternalSystemDefinition>().ToListAsync(cancellationToken);
|
||||||
|
|
||||||
@@ -34,6 +40,13 @@ public class ExternalSystemRepository : IExternalSystemRepository
|
|||||||
public async Task<ExternalSystemMethod?> GetExternalSystemMethodByIdAsync(int id, CancellationToken cancellationToken = default)
|
public async Task<ExternalSystemMethod?> GetExternalSystemMethodByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
=> await _context.Set<ExternalSystemMethod>().FindAsync(new object[] { id }, cancellationToken);
|
=> await _context.Set<ExternalSystemMethod>().FindAsync(new object[] { id }, cancellationToken);
|
||||||
|
|
||||||
|
// ExternalSystemGateway-011: genuine name-keyed query scoped to the parent system.
|
||||||
|
public async Task<ExternalSystemMethod?> GetMethodByNameAsync(int externalSystemId, string methodName, CancellationToken cancellationToken = default)
|
||||||
|
=> await _context.Set<ExternalSystemMethod>()
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
m => m.ExternalSystemDefinitionId == externalSystemId && m.Name == methodName,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
public async Task<IReadOnlyList<ExternalSystemMethod>> GetMethodsByExternalSystemIdAsync(int externalSystemId, CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<ExternalSystemMethod>> GetMethodsByExternalSystemIdAsync(int externalSystemId, CancellationToken cancellationToken = default)
|
||||||
=> await _context.Set<ExternalSystemMethod>().Where(m => m.ExternalSystemDefinitionId == externalSystemId).ToListAsync(cancellationToken);
|
=> await _context.Set<ExternalSystemMethod>().Where(m => m.ExternalSystemDefinitionId == externalSystemId).ToListAsync(cancellationToken);
|
||||||
|
|
||||||
@@ -52,6 +65,11 @@ public class ExternalSystemRepository : IExternalSystemRepository
|
|||||||
public async Task<DatabaseConnectionDefinition?> GetDatabaseConnectionByIdAsync(int id, CancellationToken cancellationToken = default)
|
public async Task<DatabaseConnectionDefinition?> GetDatabaseConnectionByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
=> await _context.Set<DatabaseConnectionDefinition>().FindAsync(new object[] { id }, cancellationToken);
|
=> await _context.Set<DatabaseConnectionDefinition>().FindAsync(new object[] { id }, cancellationToken);
|
||||||
|
|
||||||
|
// ExternalSystemGateway-011: genuine name-keyed query (server-side WHERE).
|
||||||
|
public async Task<DatabaseConnectionDefinition?> GetDatabaseConnectionByNameAsync(string name, CancellationToken cancellationToken = default)
|
||||||
|
=> await _context.Set<DatabaseConnectionDefinition>()
|
||||||
|
.FirstOrDefaultAsync(c => c.Name == name, cancellationToken);
|
||||||
|
|
||||||
public async Task<IReadOnlyList<DatabaseConnectionDefinition>> GetAllDatabaseConnectionsAsync(CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<DatabaseConnectionDefinition>> GetAllDatabaseConnectionsAsync(CancellationToken cancellationToken = default)
|
||||||
=> await _context.Set<DatabaseConnectionDefinition>().ToListAsync(cancellationToken);
|
=> await _context.Set<DatabaseConnectionDefinition>().ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -170,8 +170,10 @@ public class DatabaseGateway : IDatabaseGateway
|
|||||||
string connectionName,
|
string connectionName,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var connections = await _repository.GetAllDatabaseConnectionsAsync(cancellationToken);
|
// ExternalSystemGateway-011: name-keyed repository lookup instead of
|
||||||
return connections.FirstOrDefault(c =>
|
// fetch-all-then-filter — connection definitions are resolved on every
|
||||||
c.Name.Equals(connectionName, StringComparison.OrdinalIgnoreCase));
|
// cached write / connection request, so the repository performs an indexed
|
||||||
|
// query rather than loading every connection into memory.
|
||||||
|
return await _repository.GetDatabaseConnectionByNameAsync(connectionName, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -373,13 +373,15 @@ public class ExternalSystemClient : IExternalSystemClient
|
|||||||
string methodName,
|
string methodName,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var systems = await _repository.GetAllExternalSystemsAsync(cancellationToken);
|
// ExternalSystemGateway-011: name-keyed repository lookups instead of
|
||||||
var system = systems.FirstOrDefault(s => s.Name.Equals(systemName, StringComparison.OrdinalIgnoreCase));
|
// 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)
|
if (system == null)
|
||||||
return (null, null);
|
return (null, null);
|
||||||
|
|
||||||
var methods = await _repository.GetMethodsByExternalSystemIdAsync(system.Id, cancellationToken);
|
var method = await _repository.GetMethodByNameAsync(system.Id, methodName, cancellationToken);
|
||||||
var method = methods.FirstOrDefault(m => m.Name.Equals(methodName, StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
return (system, method);
|
return (system, method);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,31 @@ public class SiteExternalSystemRepository : IExternalSystemRepository
|
|||||||
return all.FirstOrDefault(e => e.Id == id);
|
return all.FirstOrDefault(e => e.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ExternalSystemGateway-011: genuine name-keyed lookup. The <c>external_systems</c>
|
||||||
|
/// table has <c>name</c> as its PRIMARY KEY, so this is a single indexed-row fetch
|
||||||
|
/// rather than the previous fetch-all-then-filter.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<ExternalSystemDefinition?> 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) ──
|
// ── ExternalSystemMethod (read) ──
|
||||||
|
|
||||||
public async Task<IReadOnlyList<ExternalSystemMethod>> GetMethodsByExternalSystemIdAsync(
|
public async Task<IReadOnlyList<ExternalSystemMethod>> GetMethodsByExternalSystemIdAsync(
|
||||||
@@ -80,6 +105,23 @@ public class SiteExternalSystemRepository : IExternalSystemRepository
|
|||||||
return ParseMethodDefinitions(json, externalSystemId);
|
return ParseMethodDefinitions(json, externalSystemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <see cref="GetMethodsByExternalSystemIdAsync"/>, which already performs a
|
||||||
|
/// single keyed read of the <c>method_definitions</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<ExternalSystemMethod?> 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<ExternalSystemMethod?> GetExternalSystemMethodByIdAsync(
|
public async Task<ExternalSystemMethod?> GetExternalSystemMethodByIdAsync(
|
||||||
int id, CancellationToken cancellationToken = default)
|
int id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -134,6 +176,38 @@ public class SiteExternalSystemRepository : IExternalSystemRepository
|
|||||||
return all.FirstOrDefault(d => d.Id == id);
|
return all.FirstOrDefault(d => d.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ExternalSystemGateway-011: genuine name-keyed lookup. The
|
||||||
|
/// <c>database_connections</c> table has <c>name</c> as its PRIMARY KEY, so this
|
||||||
|
/// is a single indexed-row fetch rather than fetch-all-then-filter.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<DatabaseConnectionDefinition?> 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) ──
|
// ── Write operations (not supported on site) ──
|
||||||
|
|
||||||
public Task AddExternalSystemAsync(ExternalSystemDefinition definition, CancellationToken cancellationToken = default)
|
public Task AddExternalSystemAsync(ExternalSystemDefinition definition, CancellationToken cancellationToken = default)
|
||||||
|
|||||||
@@ -76,6 +76,94 @@ public class ExternalSystemRepositoryTests : IDisposable
|
|||||||
{
|
{
|
||||||
Assert.Throws<ArgumentNullException>(() => new ExternalSystemRepository(null!));
|
Assert.Throws<ArgumentNullException>(() => 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
|
public class NotificationRepositoryTests : IDisposable
|
||||||
|
|||||||
@@ -14,10 +14,25 @@ public class DatabaseGatewayTests
|
|||||||
{
|
{
|
||||||
private readonly IExternalSystemRepository _repository = Substitute.For<IExternalSystemRepository>();
|
private readonly IExternalSystemRepository _repository = Substitute.For<IExternalSystemRepository>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures the repository substitute for the name-keyed connection-resolution
|
||||||
|
/// path used by <c>DatabaseGateway</c> (ExternalSystemGateway-011). A <c>null</c>
|
||||||
|
/// connection models a "not found" — the substitute returns <c>null</c> by default,
|
||||||
|
/// so no stub is needed for the absent entity.
|
||||||
|
/// </summary>
|
||||||
|
private void StubConnection(DatabaseConnectionDefinition? connection)
|
||||||
|
{
|
||||||
|
if (connection != null)
|
||||||
|
{
|
||||||
|
_repository.GetDatabaseConnectionByNameAsync(connection.Name, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(connection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetConnection_NotFound_Throws()
|
public async Task GetConnection_NotFound_Throws()
|
||||||
{
|
{
|
||||||
_repository.GetAllDatabaseConnectionsAsync().Returns(new List<DatabaseConnectionDefinition>());
|
StubConnection(connection: null);
|
||||||
|
|
||||||
var gateway = new DatabaseGateway(
|
var gateway = new DatabaseGateway(
|
||||||
_repository,
|
_repository,
|
||||||
@@ -31,8 +46,7 @@ public class DatabaseGatewayTests
|
|||||||
public async Task CachedWrite_NoStoreAndForward_Throws()
|
public async Task CachedWrite_NoStoreAndForward_Throws()
|
||||||
{
|
{
|
||||||
var conn = new DatabaseConnectionDefinition("testDb", "Server=localhost;Database=test") { Id = 1 };
|
var conn = new DatabaseConnectionDefinition("testDb", "Server=localhost;Database=test") { Id = 1 };
|
||||||
_repository.GetAllDatabaseConnectionsAsync()
|
StubConnection(conn);
|
||||||
.Returns(new List<DatabaseConnectionDefinition> { conn });
|
|
||||||
|
|
||||||
var gateway = new DatabaseGateway(
|
var gateway = new DatabaseGateway(
|
||||||
_repository,
|
_repository,
|
||||||
@@ -46,7 +60,7 @@ public class DatabaseGatewayTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CachedWrite_ConnectionNotFound_Throws()
|
public async Task CachedWrite_ConnectionNotFound_Throws()
|
||||||
{
|
{
|
||||||
_repository.GetAllDatabaseConnectionsAsync().Returns(new List<DatabaseConnectionDefinition>());
|
StubConnection(connection: null);
|
||||||
|
|
||||||
var gateway = new DatabaseGateway(
|
var gateway = new DatabaseGateway(
|
||||||
_repository,
|
_repository,
|
||||||
@@ -67,8 +81,7 @@ public class DatabaseGatewayTests
|
|||||||
MaxRetries = 5,
|
MaxRetries = 5,
|
||||||
RetryDelay = TimeSpan.FromSeconds(12),
|
RetryDelay = TimeSpan.FromSeconds(12),
|
||||||
};
|
};
|
||||||
_repository.GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>())
|
StubConnection(conn);
|
||||||
.Returns(new List<DatabaseConnectionDefinition> { conn });
|
|
||||||
|
|
||||||
var dbName = $"EsgCachedWrite_{Guid.NewGuid():N}";
|
var dbName = $"EsgCachedWrite_{Guid.NewGuid():N}";
|
||||||
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
|
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
|
||||||
@@ -108,8 +121,7 @@ public class DatabaseGatewayTests
|
|||||||
MaxRetries = 0,
|
MaxRetries = 0,
|
||||||
RetryDelay = TimeSpan.FromSeconds(3),
|
RetryDelay = TimeSpan.FromSeconds(3),
|
||||||
};
|
};
|
||||||
_repository.GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>())
|
StubConnection(conn);
|
||||||
.Returns(new List<DatabaseConnectionDefinition> { conn });
|
|
||||||
|
|
||||||
var dbName = $"EsgCachedWriteZero_{Guid.NewGuid():N}";
|
var dbName = $"EsgCachedWriteZero_{Guid.NewGuid():N}";
|
||||||
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
|
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
|
||||||
@@ -153,7 +165,7 @@ public class DatabaseGatewayTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeliverBuffered_ConnectionNoLongerExists_ReturnsFalseSoMessageParks()
|
public async Task DeliverBuffered_ConnectionNoLongerExists_ReturnsFalseSoMessageParks()
|
||||||
{
|
{
|
||||||
_repository.GetAllDatabaseConnectionsAsync().Returns(new List<DatabaseConnectionDefinition>());
|
StubConnection(connection: null);
|
||||||
var gateway = new DatabaseGateway(_repository, NullLogger<DatabaseGateway>.Instance);
|
var gateway = new DatabaseGateway(_repository, NullLogger<DatabaseGateway>.Instance);
|
||||||
|
|
||||||
var message = new ScadaLink.StoreAndForward.StoreAndForwardMessage
|
var message = new ScadaLink.StoreAndForward.StoreAndForwardMessage
|
||||||
@@ -176,7 +188,7 @@ public class DatabaseGatewayTests
|
|||||||
public async Task GetConnection_OpenFails_DisposesConnectionBeforeRethrowing()
|
public async Task GetConnection_OpenFails_DisposesConnectionBeforeRethrowing()
|
||||||
{
|
{
|
||||||
var conn = new DatabaseConnectionDefinition("testDb", "Server=localhost;Database=test") { Id = 1 };
|
var conn = new DatabaseConnectionDefinition("testDb", "Server=localhost;Database=test") { Id = 1 };
|
||||||
_repository.GetAllDatabaseConnectionsAsync().Returns(new List<DatabaseConnectionDefinition> { conn });
|
StubConnection(conn);
|
||||||
|
|
||||||
var fake = new ThrowingDbConnection();
|
var fake = new ThrowingDbConnection();
|
||||||
var gateway = new ConnectionFactoryStubGateway(_repository, fake);
|
var gateway = new ConnectionFactoryStubGateway(_repository, fake);
|
||||||
|
|||||||
@@ -18,10 +18,31 @@ public class ExternalSystemClientTests
|
|||||||
private readonly IExternalSystemRepository _repository = Substitute.For<IExternalSystemRepository>();
|
private readonly IExternalSystemRepository _repository = Substitute.For<IExternalSystemRepository>();
|
||||||
private readonly IHttpClientFactory _httpClientFactory = Substitute.For<IHttpClientFactory>();
|
private readonly IHttpClientFactory _httpClientFactory = Substitute.For<IHttpClientFactory>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures the repository substitute for the name-keyed resolution path used by
|
||||||
|
/// <c>ExternalSystemClient</c> (ExternalSystemGateway-011). A <c>null</c> system or
|
||||||
|
/// method models a "not found" — the substitute returns <c>null</c> by default, so
|
||||||
|
/// no stub is needed for the absent entity.
|
||||||
|
/// </summary>
|
||||||
|
private void StubResolution(ExternalSystemDefinition? system, ExternalSystemMethod? method)
|
||||||
|
{
|
||||||
|
if (system != null)
|
||||||
|
{
|
||||||
|
_repository.GetExternalSystemByNameAsync(system.Name, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(system);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (system != null && method != null)
|
||||||
|
{
|
||||||
|
_repository.GetMethodByNameAsync(system.Id, method.Name, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(method);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Call_SystemNotFound_ReturnsError()
|
public async Task Call_SystemNotFound_ReturnsError()
|
||||||
{
|
{
|
||||||
_repository.GetAllExternalSystemsAsync().Returns(new List<ExternalSystemDefinition>());
|
StubResolution(system: null, method: null);
|
||||||
|
|
||||||
var client = new ExternalSystemClient(
|
var client = new ExternalSystemClient(
|
||||||
_httpClientFactory, _repository,
|
_httpClientFactory, _repository,
|
||||||
@@ -37,8 +58,7 @@ public class ExternalSystemClientTests
|
|||||||
public async Task Call_MethodNotFound_ReturnsError()
|
public async Task Call_MethodNotFound_ReturnsError()
|
||||||
{
|
{
|
||||||
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
||||||
_repository.GetAllExternalSystemsAsync().Returns(new List<ExternalSystemDefinition> { system });
|
StubResolution(system, method: null);
|
||||||
_repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List<ExternalSystemMethod>());
|
|
||||||
|
|
||||||
var client = new ExternalSystemClient(
|
var client = new ExternalSystemClient(
|
||||||
_httpClientFactory, _repository,
|
_httpClientFactory, _repository,
|
||||||
@@ -56,8 +76,7 @@ public class ExternalSystemClientTests
|
|||||||
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
||||||
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||||
|
|
||||||
_repository.GetAllExternalSystemsAsync().Returns(new List<ExternalSystemDefinition> { system });
|
StubResolution(system, method);
|
||||||
_repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List<ExternalSystemMethod> { method });
|
|
||||||
|
|
||||||
var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{\"result\": 42}");
|
var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{\"result\": 42}");
|
||||||
var httpClient = new HttpClient(handler);
|
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 system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
||||||
var method = new ExternalSystemMethod("failMethod", "POST", "/fail") { Id = 1, ExternalSystemDefinitionId = 1 };
|
var method = new ExternalSystemMethod("failMethod", "POST", "/fail") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||||
|
|
||||||
_repository.GetAllExternalSystemsAsync().Returns(new List<ExternalSystemDefinition> { system });
|
StubResolution(system, method);
|
||||||
_repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List<ExternalSystemMethod> { method });
|
|
||||||
|
|
||||||
var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "server error");
|
var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "server error");
|
||||||
var httpClient = new HttpClient(handler);
|
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 system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
||||||
var method = new ExternalSystemMethod("badMethod", "POST", "/bad") { Id = 1, ExternalSystemDefinitionId = 1 };
|
var method = new ExternalSystemMethod("badMethod", "POST", "/bad") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||||
|
|
||||||
_repository.GetAllExternalSystemsAsync().Returns(new List<ExternalSystemDefinition> { system });
|
StubResolution(system, method);
|
||||||
_repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List<ExternalSystemMethod> { method });
|
|
||||||
|
|
||||||
var handler = new MockHttpMessageHandler(HttpStatusCode.BadRequest, "bad request");
|
var handler = new MockHttpMessageHandler(HttpStatusCode.BadRequest, "bad request");
|
||||||
var httpClient = new HttpClient(handler);
|
var httpClient = new HttpClient(handler);
|
||||||
@@ -122,7 +139,7 @@ public class ExternalSystemClientTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CachedCall_SystemNotFound_ReturnsError()
|
public async Task CachedCall_SystemNotFound_ReturnsError()
|
||||||
{
|
{
|
||||||
_repository.GetAllExternalSystemsAsync().Returns(new List<ExternalSystemDefinition>());
|
StubResolution(system: null, method: null);
|
||||||
|
|
||||||
var client = new ExternalSystemClient(
|
var client = new ExternalSystemClient(
|
||||||
_httpClientFactory, _repository,
|
_httpClientFactory, _repository,
|
||||||
@@ -140,8 +157,7 @@ public class ExternalSystemClientTests
|
|||||||
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
||||||
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||||
|
|
||||||
_repository.GetAllExternalSystemsAsync().Returns(new List<ExternalSystemDefinition> { system });
|
StubResolution(system, method);
|
||||||
_repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List<ExternalSystemMethod> { method });
|
|
||||||
|
|
||||||
var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{\"ok\": true}");
|
var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{\"ok\": true}");
|
||||||
var httpClient = new HttpClient(handler);
|
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 system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
||||||
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||||
_repository.GetAllExternalSystemsAsync().Returns(new List<ExternalSystemDefinition> { system });
|
StubResolution(system, method);
|
||||||
_repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List<ExternalSystemMethod> { method });
|
|
||||||
|
|
||||||
var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.OK, "{\"ok\":true}"));
|
var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.OK, "{\"ok\":true}"));
|
||||||
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
||||||
@@ -192,7 +207,7 @@ public class ExternalSystemClientTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeliverBuffered_SystemNoLongerExists_ReturnsFalseSoMessageParks()
|
public async Task DeliverBuffered_SystemNoLongerExists_ReturnsFalseSoMessageParks()
|
||||||
{
|
{
|
||||||
_repository.GetAllExternalSystemsAsync().Returns(new List<ExternalSystemDefinition>());
|
StubResolution(system: null, method: null);
|
||||||
|
|
||||||
var client = new ExternalSystemClient(
|
var client = new ExternalSystemClient(
|
||||||
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
|
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
|
||||||
@@ -207,8 +222,7 @@ public class ExternalSystemClientTests
|
|||||||
{
|
{
|
||||||
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
||||||
var method = new ExternalSystemMethod("failMethod", "POST", "/fail") { Id = 1, ExternalSystemDefinitionId = 1 };
|
var method = new ExternalSystemMethod("failMethod", "POST", "/fail") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||||
_repository.GetAllExternalSystemsAsync().Returns(new List<ExternalSystemDefinition> { system });
|
StubResolution(system, method);
|
||||||
_repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List<ExternalSystemMethod> { method });
|
|
||||||
|
|
||||||
var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom"));
|
var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom"));
|
||||||
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
||||||
@@ -227,10 +241,7 @@ public class ExternalSystemClientTests
|
|||||||
{
|
{
|
||||||
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
||||||
var method = new ExternalSystemMethod("postData", "POST", "/post") { Id = 1, ExternalSystemDefinitionId = 1 };
|
var method = new ExternalSystemMethod("postData", "POST", "/post") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||||
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
|
StubResolution(system, method);
|
||||||
.Returns(new List<ExternalSystemDefinition> { system });
|
|
||||||
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
|
|
||||||
.Returns(new List<ExternalSystemMethod> { method });
|
|
||||||
|
|
||||||
// The HTTP layer always fails transiently (500).
|
// The HTTP layer always fails transiently (500).
|
||||||
var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom"));
|
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 system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
||||||
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||||
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
|
StubResolution(system, method);
|
||||||
.Returns(new List<ExternalSystemDefinition> { system });
|
|
||||||
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
|
|
||||||
.Returns(new List<ExternalSystemMethod> { method });
|
|
||||||
|
|
||||||
// Handler that hangs far longer than the configured timeout and the test budget.
|
// Handler that hangs far longer than the configured timeout and the test budget.
|
||||||
var httpClient = new HttpClient(new HangingHttpMessageHandler(TimeSpan.FromMinutes(10)));
|
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 system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
||||||
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||||
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
|
StubResolution(system, method);
|
||||||
.Returns(new List<ExternalSystemDefinition> { system });
|
|
||||||
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
|
|
||||||
.Returns(new List<ExternalSystemMethod> { method });
|
|
||||||
|
|
||||||
var httpClient = new HttpClient(new HangingHttpMessageHandler(TimeSpan.FromMinutes(10)));
|
var httpClient = new HttpClient(new HangingHttpMessageHandler(TimeSpan.FromMinutes(10)));
|
||||||
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
||||||
@@ -342,10 +347,7 @@ public class ExternalSystemClientTests
|
|||||||
RetryDelay = TimeSpan.FromSeconds(42),
|
RetryDelay = TimeSpan.FromSeconds(42),
|
||||||
};
|
};
|
||||||
var method = new ExternalSystemMethod("postData", "POST", "/post") { Id = 1, ExternalSystemDefinitionId = 1 };
|
var method = new ExternalSystemMethod("postData", "POST", "/post") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||||
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
|
StubResolution(system, method);
|
||||||
.Returns(new List<ExternalSystemDefinition> { system });
|
|
||||||
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
|
|
||||||
.Returns(new List<ExternalSystemMethod> { method });
|
|
||||||
|
|
||||||
var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom"));
|
var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom"));
|
||||||
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
||||||
@@ -404,10 +406,7 @@ public class ExternalSystemClientTests
|
|||||||
RetryDelay = TimeSpan.FromSeconds(5),
|
RetryDelay = TimeSpan.FromSeconds(5),
|
||||||
};
|
};
|
||||||
var method = new ExternalSystemMethod("postData", "POST", "/post") { Id = 1, ExternalSystemDefinitionId = 1 };
|
var method = new ExternalSystemMethod("postData", "POST", "/post") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||||
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
|
StubResolution(system, method);
|
||||||
.Returns(new List<ExternalSystemDefinition> { system });
|
|
||||||
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
|
|
||||||
.Returns(new List<ExternalSystemMethod> { method });
|
|
||||||
|
|
||||||
var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom"));
|
var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom"));
|
||||||
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
||||||
@@ -443,10 +442,7 @@ public class ExternalSystemClientTests
|
|||||||
{
|
{
|
||||||
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
||||||
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||||
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
|
StubResolution(system, method);
|
||||||
.Returns(new List<ExternalSystemDefinition> { system });
|
|
||||||
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
|
|
||||||
.Returns(new List<ExternalSystemMethod> { method });
|
|
||||||
|
|
||||||
var handler = new DisposalTrackingHandler(HttpStatusCode.OK, "{\"ok\":true}");
|
var handler = new DisposalTrackingHandler(HttpStatusCode.OK, "{\"ok\":true}");
|
||||||
var httpClient = new HttpClient(handler);
|
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 system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
||||||
var method = new ExternalSystemMethod("badMethod", "POST", "/bad") { Id = 1, ExternalSystemDefinitionId = 1 };
|
var method = new ExternalSystemMethod("badMethod", "POST", "/bad") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||||
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
|
StubResolution(system, method);
|
||||||
.Returns(new List<ExternalSystemDefinition> { system });
|
|
||||||
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
|
|
||||||
.Returns(new List<ExternalSystemMethod> { method });
|
|
||||||
|
|
||||||
var handler = new DisposalTrackingHandler(HttpStatusCode.BadRequest, "bad request");
|
var handler = new DisposalTrackingHandler(HttpStatusCode.BadRequest, "bad request");
|
||||||
var httpClient = new HttpClient(handler);
|
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 system = new ExternalSystemDefinition("TestAPI", "https://api.example.com/api", "none") { Id = 1 };
|
||||||
var method = new ExternalSystemMethod("root", "GET", "") { Id = 1, ExternalSystemDefinitionId = 1 };
|
var method = new ExternalSystemMethod("root", "GET", "") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||||
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
|
StubResolution(system, method);
|
||||||
.Returns(new List<ExternalSystemDefinition> { system });
|
|
||||||
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
|
|
||||||
.Returns(new List<ExternalSystemMethod> { method });
|
|
||||||
|
|
||||||
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
|
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
|
||||||
var httpClient = new HttpClient(handler);
|
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 system = new ExternalSystemDefinition("TestAPI", "https://api.example.com/api", "none") { Id = 1 };
|
||||||
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||||
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
|
StubResolution(system, method);
|
||||||
.Returns(new List<ExternalSystemDefinition> { system });
|
|
||||||
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
|
|
||||||
.Returns(new List<ExternalSystemMethod> { method });
|
|
||||||
|
|
||||||
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
|
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
|
||||||
var httpClient = new HttpClient(handler);
|
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 system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
||||||
var method = new ExternalSystemMethod("badMethod", "POST", "/bad") { Id = 1, ExternalSystemDefinitionId = 1 };
|
var method = new ExternalSystemMethod("badMethod", "POST", "/bad") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||||
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
|
StubResolution(system, method);
|
||||||
.Returns(new List<ExternalSystemDefinition> { system });
|
|
||||||
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
|
|
||||||
.Returns(new List<ExternalSystemMethod> { method });
|
|
||||||
|
|
||||||
var hugeBody = new string('X', 500_000);
|
var hugeBody = new string('X', 500_000);
|
||||||
var handler = new MockHttpMessageHandler(HttpStatusCode.BadRequest, hugeBody);
|
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 system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
||||||
var method = new ExternalSystemMethod("postData", "POST", "/post") { Id = 1, ExternalSystemDefinitionId = 1 };
|
var method = new ExternalSystemMethod("postData", "POST", "/post") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||||
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
|
StubResolution(system, method);
|
||||||
.Returns(new List<ExternalSystemDefinition> { system });
|
|
||||||
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
|
|
||||||
.Returns(new List<ExternalSystemMethod> { method });
|
|
||||||
|
|
||||||
var httpClient = new HttpClient(new HangingHttpMessageHandler(TimeSpan.FromMinutes(10)));
|
var httpClient = new HttpClient(new HangingHttpMessageHandler(TimeSpan.FromMinutes(10)));
|
||||||
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
|
||||||
@@ -612,10 +593,7 @@ public class ExternalSystemClientTests
|
|||||||
{
|
{
|
||||||
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
||||||
var method = new ExternalSystemMethod("search", "GET", "/search") { Id = 1, ExternalSystemDefinitionId = 1 };
|
var method = new ExternalSystemMethod("search", "GET", "/search") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||||
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
|
StubResolution(system, method);
|
||||||
.Returns(new List<ExternalSystemDefinition> { system });
|
|
||||||
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
|
|
||||||
.Returns(new List<ExternalSystemMethod> { method });
|
|
||||||
|
|
||||||
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
|
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
|
||||||
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
|
||||||
@@ -642,10 +620,7 @@ public class ExternalSystemClientTests
|
|||||||
{
|
{
|
||||||
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
||||||
var method = new ExternalSystemMethod("create", "POST", "/create") { Id = 1, ExternalSystemDefinitionId = 1 };
|
var method = new ExternalSystemMethod("create", "POST", "/create") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||||
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
|
StubResolution(system, method);
|
||||||
.Returns(new List<ExternalSystemDefinition> { system });
|
|
||||||
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
|
|
||||||
.Returns(new List<ExternalSystemMethod> { method });
|
|
||||||
|
|
||||||
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
|
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
|
||||||
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
|
||||||
@@ -668,10 +643,7 @@ public class ExternalSystemClientTests
|
|||||||
AuthConfiguration = "secret-key-123",
|
AuthConfiguration = "secret-key-123",
|
||||||
};
|
};
|
||||||
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||||
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
|
StubResolution(system, method);
|
||||||
.Returns(new List<ExternalSystemDefinition> { system });
|
|
||||||
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
|
|
||||||
.Returns(new List<ExternalSystemMethod> { method });
|
|
||||||
|
|
||||||
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
|
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
|
||||||
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
|
||||||
@@ -694,10 +666,7 @@ public class ExternalSystemClientTests
|
|||||||
AuthConfiguration = "Authorization-Token:abc",
|
AuthConfiguration = "Authorization-Token:abc",
|
||||||
};
|
};
|
||||||
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||||
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
|
StubResolution(system, method);
|
||||||
.Returns(new List<ExternalSystemDefinition> { system });
|
|
||||||
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
|
|
||||||
.Returns(new List<ExternalSystemMethod> { method });
|
|
||||||
|
|
||||||
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
|
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
|
||||||
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
|
||||||
@@ -720,10 +689,7 @@ public class ExternalSystemClientTests
|
|||||||
AuthConfiguration = "alice:s3cret",
|
AuthConfiguration = "alice:s3cret",
|
||||||
};
|
};
|
||||||
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||||
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
|
StubResolution(system, method);
|
||||||
.Returns(new List<ExternalSystemDefinition> { system });
|
|
||||||
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
|
|
||||||
.Returns(new List<ExternalSystemMethod> { method });
|
|
||||||
|
|
||||||
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
|
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
|
||||||
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
|
||||||
@@ -745,10 +711,7 @@ public class ExternalSystemClientTests
|
|||||||
{
|
{
|
||||||
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
||||||
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||||
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
|
StubResolution(system, method);
|
||||||
.Returns(new List<ExternalSystemDefinition> { system });
|
|
||||||
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
|
|
||||||
.Returns(new List<ExternalSystemMethod> { method });
|
|
||||||
|
|
||||||
// A connection-level failure (e.g. host unreachable) surfaces as HttpRequestException.
|
// A connection-level failure (e.g. host unreachable) surfaces as HttpRequestException.
|
||||||
var handler = new ThrowingHttpMessageHandler(new HttpRequestException("connection refused"));
|
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 system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
||||||
var method = new ExternalSystemMethod("badMethod", "POST", "/bad") { Id = 1, ExternalSystemDefinitionId = 1 };
|
var method = new ExternalSystemMethod("badMethod", "POST", "/bad") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||||
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
|
StubResolution(system, method);
|
||||||
.Returns(new List<ExternalSystemDefinition> { system });
|
|
||||||
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
|
|
||||||
.Returns(new List<ExternalSystemMethod> { method });
|
|
||||||
|
|
||||||
var handler = new MockHttpMessageHandler(HttpStatusCode.BadRequest, "bad request");
|
var handler = new MockHttpMessageHandler(HttpStatusCode.BadRequest, "bad request");
|
||||||
var httpClient = new HttpClient(handler);
|
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 system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
|
||||||
var method = new ExternalSystemMethod("failMethod", "POST", "/fail") { Id = 1, ExternalSystemDefinitionId = 1 };
|
var method = new ExternalSystemMethod("failMethod", "POST", "/fail") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||||
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
|
StubResolution(system, method);
|
||||||
.Returns(new List<ExternalSystemDefinition> { system });
|
|
||||||
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
|
|
||||||
.Returns(new List<ExternalSystemMethod> { method });
|
|
||||||
|
|
||||||
var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom");
|
var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom");
|
||||||
var httpClient = new HttpClient(handler);
|
var httpClient = new HttpClient(handler);
|
||||||
|
|||||||
@@ -84,6 +84,101 @@ public class SiteRepositoryTests : IDisposable
|
|||||||
Assert.Equal("StableSystem", found.Name);
|
Assert.Equal("StableSystem", found.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── ExternalSystemGateway-011: name-keyed repository lookups ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ExternalSystemGateway-011: a missing name resolves to <c>null</c>.
|
||||||
|
/// </summary>
|
||||||
|
[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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ExternalSystemGateway-011: the site repository's name-keyed method lookup
|
||||||
|
/// returns the method scoped to its parent system, or <c>null</c> for a miss.
|
||||||
|
/// </summary>
|
||||||
|
[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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ExternalSystemGateway-011: the site repository's name-keyed database-connection
|
||||||
|
/// lookup returns the matching row, or <c>null</c> for a miss.
|
||||||
|
/// </summary>
|
||||||
|
[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"));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// SiteRuntime-007: the same stability guarantee for notification lists.
|
/// SiteRuntime-007: the same stability guarantee for notification lists.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user