Add unit tests and logging infrastructure for InMemory and RabbitMQ transports
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Implemented RecordingLogger and RecordingLoggerFactory for capturing log entries in tests. - Added unit tests for InMemoryChannel, covering constructor behavior, property assignments, channel communication, and disposal. - Created InMemoryTransportOptionsTests to validate default values and customizable options for InMemory transport. - Developed RabbitMqFrameProtocolTests to ensure correct parsing and property creation for RabbitMQ frames. - Added RabbitMqTransportOptionsTests to verify default settings and customization options for RabbitMQ transport. - Updated project files for testing libraries and dependencies.
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.IssuerDirectory.Core.Abstractions;
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of the issuer audit sink.
|
||||
/// </summary>
|
||||
public sealed class PostgresIssuerAuditSink : IIssuerAuditSink
|
||||
{
|
||||
private readonly IssuerDirectoryDataSource _dataSource;
|
||||
private readonly ILogger<PostgresIssuerAuditSink> _logger;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public PostgresIssuerAuditSink(IssuerDirectoryDataSource dataSource, ILogger<PostgresIssuerAuditSink> logger)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task WriteAsync(IssuerAuditEntry entry, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(entry.TenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO issuer.audit (tenant_id, actor, action, issuer_id, reason, details, occurred_at)
|
||||
VALUES (@tenantId::uuid, @actor, @action, @issuerId::uuid, @reason, @details::jsonb, @occurredAt)
|
||||
""";
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
|
||||
command.Parameters.AddWithValue("tenantId", Guid.Parse(entry.TenantId));
|
||||
command.Parameters.AddWithValue("actor", entry.Actor);
|
||||
command.Parameters.AddWithValue("action", entry.Action);
|
||||
command.Parameters.AddWithValue("issuerId", Guid.Parse(entry.IssuerId));
|
||||
command.Parameters.Add(new NpgsqlParameter("reason", NpgsqlDbType.Text) { Value = entry.Reason ?? (object)DBNull.Value });
|
||||
command.Parameters.Add(new NpgsqlParameter("details", NpgsqlDbType.Jsonb) { Value = SerializeMetadata(entry.Metadata) });
|
||||
command.Parameters.AddWithValue("occurredAt", entry.TimestampUtc.UtcDateTime);
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Wrote audit entry: {Action} for issuer {IssuerId} by {Actor}.", entry.Action, entry.IssuerId, entry.Actor);
|
||||
}
|
||||
|
||||
private static string SerializeMetadata(IReadOnlyDictionary<string, string> metadata)
|
||||
{
|
||||
if (metadata.Count == 0)
|
||||
{
|
||||
return "{}";
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(metadata, JsonOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.IssuerDirectory.Core.Abstractions;
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of the issuer key repository.
|
||||
/// </summary>
|
||||
public sealed class PostgresIssuerKeyRepository : IIssuerKeyRepository
|
||||
{
|
||||
private readonly IssuerDirectoryDataSource _dataSource;
|
||||
private readonly ILogger<PostgresIssuerKeyRepository> _logger;
|
||||
|
||||
public PostgresIssuerKeyRepository(IssuerDirectoryDataSource dataSource, ILogger<PostgresIssuerKeyRepository> logger)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<IssuerKeyRecord?> GetAsync(string tenantId, string issuerId, string keyId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT id, issuer_id, tenant_id, key_id, key_type, public_key, fingerprint, not_before, not_after, status, replaces_key_id, created_at, created_by, updated_at, updated_by, retired_at, revoked_at, revoke_reason, metadata
|
||||
FROM issuer.issuer_keys
|
||||
WHERE tenant_id = @tenantId::uuid AND issuer_id = @issuerId::uuid AND key_id = @keyId
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("tenantId", tenantId);
|
||||
command.Parameters.AddWithValue("issuerId", issuerId);
|
||||
command.Parameters.AddWithValue("keyId", keyId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapToRecord(reader);
|
||||
}
|
||||
|
||||
public async Task<IssuerKeyRecord?> GetByFingerprintAsync(string tenantId, string issuerId, string fingerprint, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT id, issuer_id, tenant_id, key_id, key_type, public_key, fingerprint, not_before, not_after, status, replaces_key_id, created_at, created_by, updated_at, updated_by, retired_at, revoked_at, revoke_reason, metadata
|
||||
FROM issuer.issuer_keys
|
||||
WHERE tenant_id = @tenantId::uuid AND issuer_id = @issuerId::uuid AND fingerprint = @fingerprint
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("tenantId", tenantId);
|
||||
command.Parameters.AddWithValue("issuerId", issuerId);
|
||||
command.Parameters.AddWithValue("fingerprint", fingerprint);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapToRecord(reader);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<IssuerKeyRecord>> ListAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT id, issuer_id, tenant_id, key_id, key_type, public_key, fingerprint, not_before, not_after, status, replaces_key_id, created_at, created_by, updated_at, updated_by, retired_at, revoked_at, revoke_reason, metadata
|
||||
FROM issuer.issuer_keys
|
||||
WHERE tenant_id = @tenantId::uuid AND issuer_id = @issuerId::uuid
|
||||
ORDER BY created_at ASC
|
||||
""";
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("tenantId", tenantId);
|
||||
command.Parameters.AddWithValue("issuerId", issuerId);
|
||||
|
||||
return await ReadAllRecordsAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<IssuerKeyRecord>> ListGlobalAsync(string issuerId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT id, issuer_id, tenant_id, key_id, key_type, public_key, fingerprint, not_before, not_after, status, replaces_key_id, created_at, created_by, updated_at, updated_by, retired_at, revoked_at, revoke_reason, metadata
|
||||
FROM issuer.issuer_keys
|
||||
WHERE tenant_id = @globalTenantId::uuid AND issuer_id = @issuerId::uuid
|
||||
ORDER BY created_at ASC
|
||||
""";
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("globalTenantId", IssuerTenants.Global);
|
||||
command.Parameters.AddWithValue("issuerId", issuerId);
|
||||
|
||||
return await ReadAllRecordsAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(IssuerKeyRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(record.TenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO issuer.issuer_keys (id, issuer_id, tenant_id, key_id, key_type, public_key, fingerprint, not_before, not_after, status, replaces_key_id, created_at, created_by, updated_at, updated_by, retired_at, revoked_at, revoke_reason, metadata)
|
||||
VALUES (@id::uuid, @issuerId::uuid, @tenantId::uuid, @keyId, @keyType, @publicKey, @fingerprint, @notBefore, @notAfter, @status, @replacesKeyId, @createdAt, @createdBy, @updatedAt, @updatedBy, @retiredAt, @revokedAt, @revokeReason, @metadata::jsonb)
|
||||
ON CONFLICT (issuer_id, key_id)
|
||||
DO UPDATE SET
|
||||
key_type = EXCLUDED.key_type,
|
||||
public_key = EXCLUDED.public_key,
|
||||
fingerprint = EXCLUDED.fingerprint,
|
||||
not_before = EXCLUDED.not_before,
|
||||
not_after = EXCLUDED.not_after,
|
||||
status = EXCLUDED.status,
|
||||
replaces_key_id = EXCLUDED.replaces_key_id,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
updated_by = EXCLUDED.updated_by,
|
||||
retired_at = EXCLUDED.retired_at,
|
||||
revoked_at = EXCLUDED.revoked_at,
|
||||
revoke_reason = EXCLUDED.revoke_reason,
|
||||
metadata = EXCLUDED.metadata
|
||||
""";
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
|
||||
command.Parameters.AddWithValue("id", Guid.Parse(record.Id));
|
||||
command.Parameters.AddWithValue("issuerId", Guid.Parse(record.IssuerId));
|
||||
command.Parameters.AddWithValue("tenantId", Guid.Parse(record.TenantId));
|
||||
command.Parameters.AddWithValue("keyId", record.Id);
|
||||
command.Parameters.AddWithValue("keyType", MapKeyType(record.Type));
|
||||
command.Parameters.AddWithValue("publicKey", record.Material.Value);
|
||||
command.Parameters.AddWithValue("fingerprint", record.Fingerprint);
|
||||
command.Parameters.Add(new NpgsqlParameter("notBefore", NpgsqlDbType.TimestampTz) { Value = (object?)null ?? DBNull.Value });
|
||||
command.Parameters.Add(new NpgsqlParameter("notAfter", NpgsqlDbType.TimestampTz) { Value = record.ExpiresAtUtc.HasValue ? record.ExpiresAtUtc.Value.UtcDateTime : DBNull.Value });
|
||||
command.Parameters.AddWithValue("status", record.Status.ToString().ToLowerInvariant());
|
||||
command.Parameters.Add(new NpgsqlParameter("replacesKeyId", NpgsqlDbType.Text) { Value = record.ReplacesKeyId ?? (object)DBNull.Value });
|
||||
command.Parameters.AddWithValue("createdAt", record.CreatedAtUtc.UtcDateTime);
|
||||
command.Parameters.AddWithValue("createdBy", record.CreatedBy);
|
||||
command.Parameters.AddWithValue("updatedAt", record.UpdatedAtUtc.UtcDateTime);
|
||||
command.Parameters.AddWithValue("updatedBy", record.UpdatedBy);
|
||||
command.Parameters.Add(new NpgsqlParameter("retiredAt", NpgsqlDbType.TimestampTz) { Value = record.RetiredAtUtc.HasValue ? record.RetiredAtUtc.Value.UtcDateTime : DBNull.Value });
|
||||
command.Parameters.Add(new NpgsqlParameter("revokedAt", NpgsqlDbType.TimestampTz) { Value = record.RevokedAtUtc.HasValue ? record.RevokedAtUtc.Value.UtcDateTime : DBNull.Value });
|
||||
command.Parameters.Add(new NpgsqlParameter("revokeReason", NpgsqlDbType.Text) { Value = DBNull.Value });
|
||||
command.Parameters.Add(new NpgsqlParameter("metadata", NpgsqlDbType.Jsonb) { Value = "{}" });
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Upserted issuer key {KeyId} for issuer {IssuerId}.", record.Id, record.IssuerId);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyCollection<IssuerKeyRecord>> ReadAllRecordsAsync(NpgsqlCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<IssuerKeyRecord>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapToRecord(reader));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private static IssuerKeyRecord MapToRecord(NpgsqlDataReader reader)
|
||||
{
|
||||
var id = reader.GetGuid(0).ToString();
|
||||
var issuerId = reader.GetGuid(1).ToString();
|
||||
var tenantId = reader.GetGuid(2).ToString();
|
||||
var keyId = reader.GetString(3);
|
||||
var keyType = ParseKeyType(reader.GetString(4));
|
||||
var publicKey = reader.GetString(5);
|
||||
var fingerprint = reader.GetString(6);
|
||||
var notBefore = reader.IsDBNull(7) ? (DateTimeOffset?)null : new DateTimeOffset(reader.GetDateTime(7), TimeSpan.Zero);
|
||||
var notAfter = reader.IsDBNull(8) ? (DateTimeOffset?)null : new DateTimeOffset(reader.GetDateTime(8), TimeSpan.Zero);
|
||||
var status = ParseKeyStatus(reader.GetString(9));
|
||||
var replacesKeyId = reader.IsDBNull(10) ? null : reader.GetString(10);
|
||||
var createdAt = reader.GetDateTime(11);
|
||||
var createdBy = reader.GetString(12);
|
||||
var updatedAt = reader.GetDateTime(13);
|
||||
var updatedBy = reader.GetString(14);
|
||||
var retiredAt = reader.IsDBNull(15) ? (DateTimeOffset?)null : new DateTimeOffset(reader.GetDateTime(15), TimeSpan.Zero);
|
||||
var revokedAt = reader.IsDBNull(16) ? (DateTimeOffset?)null : new DateTimeOffset(reader.GetDateTime(16), TimeSpan.Zero);
|
||||
|
||||
return new IssuerKeyRecord
|
||||
{
|
||||
Id = keyId,
|
||||
IssuerId = issuerId,
|
||||
TenantId = tenantId,
|
||||
Type = keyType,
|
||||
Status = status,
|
||||
Material = new IssuerKeyMaterial("pem", publicKey),
|
||||
Fingerprint = fingerprint,
|
||||
CreatedAtUtc = new DateTimeOffset(createdAt, TimeSpan.Zero),
|
||||
CreatedBy = createdBy,
|
||||
UpdatedAtUtc = new DateTimeOffset(updatedAt, TimeSpan.Zero),
|
||||
UpdatedBy = updatedBy,
|
||||
ExpiresAtUtc = notAfter,
|
||||
RetiredAtUtc = retiredAt,
|
||||
RevokedAtUtc = revokedAt,
|
||||
ReplacesKeyId = replacesKeyId
|
||||
};
|
||||
}
|
||||
|
||||
private static string MapKeyType(IssuerKeyType type) => type switch
|
||||
{
|
||||
IssuerKeyType.Ed25519PublicKey => "ed25519",
|
||||
IssuerKeyType.X509Certificate => "x509",
|
||||
IssuerKeyType.DssePublicKey => "dsse",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(type), type, "Unsupported key type")
|
||||
};
|
||||
|
||||
private static IssuerKeyType ParseKeyType(string value) => value.ToLowerInvariant() switch
|
||||
{
|
||||
"ed25519" => IssuerKeyType.Ed25519PublicKey,
|
||||
"x509" => IssuerKeyType.X509Certificate,
|
||||
"dsse" => IssuerKeyType.DssePublicKey,
|
||||
_ => throw new ArgumentException($"Unknown key type: {value}", nameof(value))
|
||||
};
|
||||
|
||||
private static IssuerKeyStatus ParseKeyStatus(string value) => value.ToLowerInvariant() switch
|
||||
{
|
||||
"active" => IssuerKeyStatus.Active,
|
||||
"retired" => IssuerKeyStatus.Retired,
|
||||
"revoked" => IssuerKeyStatus.Revoked,
|
||||
_ => throw new ArgumentException($"Unknown key status: {value}", nameof(value))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.IssuerDirectory.Core.Abstractions;
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of the issuer repository.
|
||||
/// </summary>
|
||||
public sealed class PostgresIssuerRepository : IIssuerRepository
|
||||
{
|
||||
private readonly IssuerDirectoryDataSource _dataSource;
|
||||
private readonly ILogger<PostgresIssuerRepository> _logger;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public PostgresIssuerRepository(IssuerDirectoryDataSource dataSource, ILogger<PostgresIssuerRepository> logger)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<IssuerRecord?> GetAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, name, display_name, description, endpoints, contact, metadata, tags, status, is_system_seed, created_at, created_by, updated_at, updated_by
|
||||
FROM issuer.issuers
|
||||
WHERE tenant_id = @tenantId::uuid AND id = @issuerId::uuid
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("tenantId", tenantId);
|
||||
command.Parameters.AddWithValue("issuerId", issuerId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapToRecord(reader);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<IssuerRecord>> ListAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, name, display_name, description, endpoints, contact, metadata, tags, status, is_system_seed, created_at, created_by, updated_at, updated_by
|
||||
FROM issuer.issuers
|
||||
WHERE tenant_id = @tenantId::uuid
|
||||
ORDER BY name ASC
|
||||
""";
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("tenantId", tenantId);
|
||||
|
||||
return await ReadAllRecordsAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<IssuerRecord>> ListGlobalAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, name, display_name, description, endpoints, contact, metadata, tags, status, is_system_seed, created_at, created_by, updated_at, updated_by
|
||||
FROM issuer.issuers
|
||||
WHERE tenant_id = @globalTenantId::uuid
|
||||
ORDER BY name ASC
|
||||
""";
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("globalTenantId", IssuerTenants.Global);
|
||||
|
||||
return await ReadAllRecordsAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(IssuerRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(record.TenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO issuer.issuers (id, tenant_id, name, display_name, description, endpoints, contact, metadata, tags, status, is_system_seed, created_at, created_by, updated_at, updated_by)
|
||||
VALUES (@id::uuid, @tenantId::uuid, @name, @displayName, @description, @endpoints::jsonb, @contact::jsonb, @metadata::jsonb, @tags, @status, @isSystemSeed, @createdAt, @createdBy, @updatedAt, @updatedBy)
|
||||
ON CONFLICT (tenant_id, name)
|
||||
DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
description = EXCLUDED.description,
|
||||
endpoints = EXCLUDED.endpoints,
|
||||
contact = EXCLUDED.contact,
|
||||
metadata = EXCLUDED.metadata,
|
||||
tags = EXCLUDED.tags,
|
||||
status = EXCLUDED.status,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
updated_by = EXCLUDED.updated_by
|
||||
""";
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
|
||||
command.Parameters.AddWithValue("id", Guid.Parse(record.Id));
|
||||
command.Parameters.AddWithValue("tenantId", Guid.Parse(record.TenantId));
|
||||
command.Parameters.AddWithValue("name", record.Slug);
|
||||
command.Parameters.AddWithValue("displayName", record.DisplayName);
|
||||
command.Parameters.Add(new NpgsqlParameter("description", NpgsqlDbType.Text) { Value = record.Description ?? (object)DBNull.Value });
|
||||
command.Parameters.Add(new NpgsqlParameter("endpoints", NpgsqlDbType.Jsonb) { Value = SerializeEndpoints(record.Endpoints) });
|
||||
command.Parameters.Add(new NpgsqlParameter("contact", NpgsqlDbType.Jsonb) { Value = SerializeContact(record.Contact) });
|
||||
command.Parameters.Add(new NpgsqlParameter("metadata", NpgsqlDbType.Jsonb) { Value = SerializeMetadata(record.Metadata) });
|
||||
command.Parameters.Add(new NpgsqlParameter("tags", NpgsqlDbType.Array | NpgsqlDbType.Text) { Value = record.Tags.ToArray() });
|
||||
command.Parameters.AddWithValue("status", "active");
|
||||
command.Parameters.AddWithValue("isSystemSeed", record.IsSystemSeed);
|
||||
command.Parameters.AddWithValue("createdAt", record.CreatedAtUtc);
|
||||
command.Parameters.AddWithValue("createdBy", record.CreatedBy);
|
||||
command.Parameters.AddWithValue("updatedAt", record.UpdatedAtUtc);
|
||||
command.Parameters.AddWithValue("updatedBy", record.UpdatedBy);
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Upserted issuer {IssuerId} for tenant {TenantId}.", record.Id, record.TenantId);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = "DELETE FROM issuer.issuers WHERE tenant_id = @tenantId::uuid AND id = @issuerId::uuid";
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("tenantId", tenantId);
|
||||
command.Parameters.AddWithValue("issuerId", issuerId);
|
||||
|
||||
var rowsAffected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogDebug("Deleted issuer {IssuerId} for tenant {TenantId}. Rows affected: {Rows}.", issuerId, tenantId, rowsAffected);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyCollection<IssuerRecord>> ReadAllRecordsAsync(NpgsqlCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<IssuerRecord>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapToRecord(reader));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private static IssuerRecord MapToRecord(NpgsqlDataReader reader)
|
||||
{
|
||||
var id = reader.GetGuid(0).ToString();
|
||||
var tenantId = reader.GetGuid(1).ToString();
|
||||
var name = reader.GetString(2);
|
||||
var displayName = reader.GetString(3);
|
||||
var description = reader.IsDBNull(4) ? null : reader.GetString(4);
|
||||
var endpointsJson = reader.GetString(5);
|
||||
var contactJson = reader.GetString(6);
|
||||
var metadataJson = reader.GetString(7);
|
||||
var tags = reader.GetFieldValue<string[]>(8);
|
||||
var isSystemSeed = reader.GetBoolean(10);
|
||||
var createdAt = reader.GetDateTime(11);
|
||||
var createdBy = reader.GetString(12);
|
||||
var updatedAt = reader.GetDateTime(13);
|
||||
var updatedBy = reader.GetString(14);
|
||||
|
||||
var contact = DeserializeContact(contactJson);
|
||||
var metadata = DeserializeMetadata(metadataJson);
|
||||
var endpoints = DeserializeEndpoints(endpointsJson);
|
||||
|
||||
return new IssuerRecord
|
||||
{
|
||||
Id = id,
|
||||
TenantId = tenantId,
|
||||
Slug = name,
|
||||
DisplayName = displayName,
|
||||
Description = description,
|
||||
Contact = contact,
|
||||
Metadata = metadata,
|
||||
Endpoints = endpoints,
|
||||
Tags = tags,
|
||||
IsSystemSeed = isSystemSeed,
|
||||
CreatedAtUtc = new DateTimeOffset(createdAt, TimeSpan.Zero),
|
||||
CreatedBy = createdBy,
|
||||
UpdatedAtUtc = new DateTimeOffset(updatedAt, TimeSpan.Zero),
|
||||
UpdatedBy = updatedBy
|
||||
};
|
||||
}
|
||||
|
||||
private static string SerializeContact(IssuerContact contact)
|
||||
{
|
||||
var doc = new
|
||||
{
|
||||
email = contact.Email,
|
||||
phone = contact.Phone,
|
||||
website = contact.Website?.ToString(),
|
||||
timezone = contact.Timezone
|
||||
};
|
||||
return JsonSerializer.Serialize(doc, JsonOptions);
|
||||
}
|
||||
|
||||
private static IssuerContact DeserializeContact(string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
var email = root.TryGetProperty("email", out var e) && e.ValueKind != JsonValueKind.Null ? e.GetString() : null;
|
||||
var phone = root.TryGetProperty("phone", out var p) && p.ValueKind != JsonValueKind.Null ? p.GetString() : null;
|
||||
var websiteStr = root.TryGetProperty("website", out var w) && w.ValueKind != JsonValueKind.Null ? w.GetString() : null;
|
||||
var timezone = root.TryGetProperty("timezone", out var t) && t.ValueKind != JsonValueKind.Null ? t.GetString() : null;
|
||||
return new IssuerContact(email, phone, string.IsNullOrWhiteSpace(websiteStr) ? null : new Uri(websiteStr), timezone);
|
||||
}
|
||||
|
||||
private static string SerializeMetadata(IssuerMetadata metadata)
|
||||
{
|
||||
var doc = new
|
||||
{
|
||||
cveOrgId = metadata.CveOrgId,
|
||||
csafPublisherId = metadata.CsafPublisherId,
|
||||
securityAdvisoriesUrl = metadata.SecurityAdvisoriesUrl?.ToString(),
|
||||
catalogUrl = metadata.CatalogUrl?.ToString(),
|
||||
languages = metadata.SupportedLanguages.ToList(),
|
||||
attributes = new Dictionary<string, string>(metadata.Attributes)
|
||||
};
|
||||
return JsonSerializer.Serialize(doc, JsonOptions);
|
||||
}
|
||||
|
||||
private static IssuerMetadata DeserializeMetadata(string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
var cveOrgId = root.TryGetProperty("cveOrgId", out var c) && c.ValueKind != JsonValueKind.Null ? c.GetString() : null;
|
||||
var csafPublisherId = root.TryGetProperty("csafPublisherId", out var cp) && cp.ValueKind != JsonValueKind.Null ? cp.GetString() : null;
|
||||
var securityAdvisoriesUrlStr = root.TryGetProperty("securityAdvisoriesUrl", out var sa) && sa.ValueKind != JsonValueKind.Null ? sa.GetString() : null;
|
||||
var catalogUrlStr = root.TryGetProperty("catalogUrl", out var cu) && cu.ValueKind != JsonValueKind.Null ? cu.GetString() : null;
|
||||
|
||||
var languages = new List<string>();
|
||||
if (root.TryGetProperty("languages", out var langs) && langs.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var lang in langs.EnumerateArray())
|
||||
{
|
||||
if (lang.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
languages.Add(lang.GetString()!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var attributes = new Dictionary<string, string>();
|
||||
if (root.TryGetProperty("attributes", out var attrs) && attrs.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in attrs.EnumerateObject())
|
||||
{
|
||||
if (prop.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
attributes[prop.Name] = prop.Value.GetString()!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new IssuerMetadata(
|
||||
cveOrgId,
|
||||
csafPublisherId,
|
||||
string.IsNullOrWhiteSpace(securityAdvisoriesUrlStr) ? null : new Uri(securityAdvisoriesUrlStr),
|
||||
string.IsNullOrWhiteSpace(catalogUrlStr) ? null : new Uri(catalogUrlStr),
|
||||
languages,
|
||||
attributes);
|
||||
}
|
||||
|
||||
private static string SerializeEndpoints(IReadOnlyCollection<IssuerEndpoint> endpoints)
|
||||
{
|
||||
var docs = endpoints.Select(e => new
|
||||
{
|
||||
kind = e.Kind,
|
||||
url = e.Url.ToString(),
|
||||
format = e.Format,
|
||||
requiresAuthentication = e.RequiresAuthentication
|
||||
}).ToList();
|
||||
return JsonSerializer.Serialize(docs, JsonOptions);
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<IssuerEndpoint> DeserializeEndpoints(string json)
|
||||
{
|
||||
var results = new List<IssuerEndpoint>();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return results;
|
||||
}
|
||||
|
||||
foreach (var elem in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
var kind = elem.TryGetProperty("kind", out var k) ? k.GetString() : null;
|
||||
var urlStr = elem.TryGetProperty("url", out var u) ? u.GetString() : null;
|
||||
var format = elem.TryGetProperty("format", out var f) && f.ValueKind != JsonValueKind.Null ? f.GetString() : null;
|
||||
var requiresAuth = elem.TryGetProperty("requiresAuthentication", out var ra) && ra.GetBoolean();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(kind) && !string.IsNullOrWhiteSpace(urlStr))
|
||||
{
|
||||
results.Add(new IssuerEndpoint(kind, new Uri(urlStr), format, requiresAuth));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.IssuerDirectory.Core.Abstractions;
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of the issuer trust repository.
|
||||
/// </summary>
|
||||
public sealed class PostgresIssuerTrustRepository : IIssuerTrustRepository
|
||||
{
|
||||
private readonly IssuerDirectoryDataSource _dataSource;
|
||||
private readonly ILogger<PostgresIssuerTrustRepository> _logger;
|
||||
|
||||
public PostgresIssuerTrustRepository(IssuerDirectoryDataSource dataSource, ILogger<PostgresIssuerTrustRepository> logger)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<IssuerTrustOverrideRecord?> GetAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT id, issuer_id, tenant_id, weight, rationale, expires_at, created_at, created_by, updated_at, updated_by
|
||||
FROM issuer.trust_overrides
|
||||
WHERE tenant_id = @tenantId::uuid AND issuer_id = @issuerId::uuid
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("tenantId", tenantId);
|
||||
command.Parameters.AddWithValue("issuerId", issuerId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapToRecord(reader);
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(IssuerTrustOverrideRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(record.TenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO issuer.trust_overrides (issuer_id, tenant_id, weight, rationale, created_at, created_by, updated_at, updated_by)
|
||||
VALUES (@issuerId::uuid, @tenantId::uuid, @weight, @rationale, @createdAt, @createdBy, @updatedAt, @updatedBy)
|
||||
ON CONFLICT (issuer_id, tenant_id)
|
||||
DO UPDATE SET
|
||||
weight = EXCLUDED.weight,
|
||||
rationale = EXCLUDED.rationale,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
updated_by = EXCLUDED.updated_by
|
||||
""";
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
|
||||
command.Parameters.AddWithValue("issuerId", Guid.Parse(record.IssuerId));
|
||||
command.Parameters.AddWithValue("tenantId", Guid.Parse(record.TenantId));
|
||||
command.Parameters.AddWithValue("weight", record.Weight);
|
||||
command.Parameters.Add(new NpgsqlParameter("rationale", NpgsqlDbType.Text) { Value = record.Reason ?? (object)DBNull.Value });
|
||||
command.Parameters.AddWithValue("createdAt", record.CreatedAtUtc.UtcDateTime);
|
||||
command.Parameters.AddWithValue("createdBy", record.CreatedBy);
|
||||
command.Parameters.AddWithValue("updatedAt", record.UpdatedAtUtc.UtcDateTime);
|
||||
command.Parameters.AddWithValue("updatedBy", record.UpdatedBy);
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Upserted trust override for issuer {IssuerId} in tenant {TenantId}.", record.IssuerId, record.TenantId);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = "DELETE FROM issuer.trust_overrides WHERE tenant_id = @tenantId::uuid AND issuer_id = @issuerId::uuid";
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("tenantId", tenantId);
|
||||
command.Parameters.AddWithValue("issuerId", issuerId);
|
||||
|
||||
var rowsAffected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogDebug("Deleted trust override for issuer {IssuerId} in tenant {TenantId}. Rows affected: {Rows}.", issuerId, tenantId, rowsAffected);
|
||||
}
|
||||
|
||||
private static IssuerTrustOverrideRecord MapToRecord(NpgsqlDataReader reader)
|
||||
{
|
||||
var issuerId = reader.GetGuid(1).ToString();
|
||||
var tenantId = reader.GetGuid(2).ToString();
|
||||
var weight = reader.GetDecimal(3);
|
||||
var rationale = reader.IsDBNull(4) ? null : reader.GetString(4);
|
||||
var createdAt = reader.GetDateTime(6);
|
||||
var createdBy = reader.GetString(7);
|
||||
var updatedAt = reader.GetDateTime(8);
|
||||
var updatedBy = reader.GetString(9);
|
||||
|
||||
return new IssuerTrustOverrideRecord
|
||||
{
|
||||
IssuerId = issuerId,
|
||||
TenantId = tenantId,
|
||||
Weight = weight,
|
||||
Reason = rationale,
|
||||
CreatedAtUtc = new DateTimeOffset(createdAt, TimeSpan.Zero),
|
||||
CreatedBy = createdBy,
|
||||
UpdatedAtUtc = new DateTimeOffset(updatedAt, TimeSpan.Zero),
|
||||
UpdatedBy = updatedBy
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using StellaOps.IssuerDirectory.Core.Abstractions;
|
||||
using StellaOps.IssuerDirectory.Storage.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Storage.Postgres;
|
||||
|
||||
@@ -34,6 +36,8 @@ public static class ServiceCollectionExtensions
|
||||
return new IssuerDirectoryDataSource(options, logger);
|
||||
});
|
||||
|
||||
RegisterRepositories(services);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -61,6 +65,16 @@ public static class ServiceCollectionExtensions
|
||||
return new IssuerDirectoryDataSource(options, logger);
|
||||
});
|
||||
|
||||
RegisterRepositories(services);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void RegisterRepositories(IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IIssuerRepository, PostgresIssuerRepository>();
|
||||
services.AddScoped<IIssuerKeyRepository, PostgresIssuerKeyRepository>();
|
||||
services.AddScoped<IIssuerTrustRepository, PostgresIssuerTrustRepository>();
|
||||
services.AddScoped<IIssuerAuditSink, PostgresIssuerAuditSink>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ public sealed class IssuerDirectoryWebServiceOptions
|
||||
|
||||
public AuthorityOptions Authority { get; set; } = new();
|
||||
|
||||
public PersistenceOptions Persistence { get; set; } = new();
|
||||
|
||||
public string TenantHeader { get; set; } = "X-StellaOps-Tenant";
|
||||
|
||||
public bool SeedCsafPublishers { get; set; } = true;
|
||||
@@ -24,6 +26,7 @@ public sealed class IssuerDirectoryWebServiceOptions
|
||||
}
|
||||
|
||||
Authority.Validate();
|
||||
Persistence.Validate();
|
||||
}
|
||||
|
||||
public sealed class TelemetryOptions
|
||||
@@ -74,4 +77,31 @@ public sealed class IssuerDirectoryWebServiceOptions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PersistenceOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Storage provider for IssuerDirectory. Valid values: "Mongo", "Postgres".
|
||||
/// </summary>
|
||||
public string Provider { get; set; } = "Mongo";
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL connection string. Required when Provider is "Postgres".
|
||||
/// </summary>
|
||||
public string PostgresConnectionString { get; set; } = string.Empty;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
var normalized = Provider?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||
if (normalized != "mongo" && normalized != "postgres")
|
||||
{
|
||||
throw new InvalidOperationException($"IssuerDirectory persistence provider '{Provider}' is not supported. Use 'Mongo' or 'Postgres'.");
|
||||
}
|
||||
|
||||
if (normalized == "postgres" && string.IsNullOrWhiteSpace(PostgresConnectionString))
|
||||
{
|
||||
throw new InvalidOperationException("PostgreSQL connection string is required when persistence provider is 'Postgres'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.IssuerDirectory.Core.Services;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using StellaOps.IssuerDirectory.Infrastructure;
|
||||
using StellaOps.IssuerDirectory.Storage.Postgres;
|
||||
using StellaOps.IssuerDirectory.Infrastructure.Seed;
|
||||
using StellaOps.IssuerDirectory.WebService.Endpoints;
|
||||
using StellaOps.IssuerDirectory.WebService.Options;
|
||||
@@ -58,7 +60,10 @@ builder.Services.AddOptions<IssuerDirectoryWebServiceOptions>()
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddIssuerDirectoryInfrastructure(builder.Configuration);
|
||||
|
||||
// Configure persistence based on provider
|
||||
ConfigurePersistence(builder, bootstrapOptions);
|
||||
|
||||
builder.Services.AddSingleton<IssuerDirectoryService>();
|
||||
builder.Services.AddSingleton<IssuerKeyService>();
|
||||
builder.Services.AddSingleton<IssuerTrustService>();
|
||||
@@ -112,6 +117,28 @@ static LogEventLevel MapLogLevel(string? value)
|
||||
: LogEventLevel.Information;
|
||||
}
|
||||
|
||||
static void ConfigurePersistence(
|
||||
WebApplicationBuilder builder,
|
||||
IssuerDirectoryWebServiceOptions options)
|
||||
{
|
||||
var provider = options.Persistence.Provider?.Trim().ToLowerInvariant() ?? "mongo";
|
||||
|
||||
if (provider == "postgres")
|
||||
{
|
||||
Log.Information("Using PostgreSQL persistence for IssuerDirectory.");
|
||||
builder.Services.AddIssuerDirectoryPostgresStorage(new PostgresOptions
|
||||
{
|
||||
ConnectionString = options.Persistence.PostgresConnectionString,
|
||||
SchemaName = "issuer"
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Information("Using MongoDB persistence for IssuerDirectory.");
|
||||
builder.Services.AddIssuerDirectoryInfrastructure(builder.Configuration);
|
||||
}
|
||||
}
|
||||
|
||||
static void ConfigureAuthentication(
|
||||
WebApplicationBuilder builder,
|
||||
IssuerDirectoryWebServiceOptions options)
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\StellaOps.IssuerDirectory.Core\\StellaOps.IssuerDirectory.Core.csproj" />
|
||||
<ProjectReference Include="..\\StellaOps.IssuerDirectory.Infrastructure\\StellaOps.IssuerDirectory.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\\StellaOps.IssuerDirectory.Storage.Postgres\\StellaOps.IssuerDirectory.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="..\\..\\..\\Authority\\StellaOps.Authority\\StellaOps.Auth.Abstractions\\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\\..\\..\\Authority\\StellaOps.Authority\\StellaOps.Auth.ServerIntegration\\StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Configuration\\StellaOps.Configuration.csproj" />
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using StellaOps.IssuerDirectory.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Storage.Postgres.Tests;
|
||||
|
||||
[Collection(IssuerDirectoryPostgresCollection.Name)]
|
||||
public sealed class IssuerAuditSinkTests : IAsyncLifetime
|
||||
{
|
||||
private readonly IssuerDirectoryPostgresFixture _fixture;
|
||||
private readonly PostgresIssuerRepository _issuerRepository;
|
||||
private readonly PostgresIssuerAuditSink _auditSink;
|
||||
private readonly IssuerDirectoryDataSource _dataSource;
|
||||
private readonly string _tenantId = Guid.NewGuid().ToString();
|
||||
private string _issuerId = null!;
|
||||
|
||||
public IssuerAuditSinkTests(IssuerDirectoryPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
|
||||
var options = new PostgresOptions
|
||||
{
|
||||
ConnectionString = fixture.ConnectionString,
|
||||
SchemaName = fixture.SchemaName
|
||||
};
|
||||
_dataSource = new IssuerDirectoryDataSource(options, NullLogger<IssuerDirectoryDataSource>.Instance);
|
||||
_issuerRepository = new PostgresIssuerRepository(_dataSource, NullLogger<PostgresIssuerRepository>.Instance);
|
||||
_auditSink = new PostgresIssuerAuditSink(_dataSource, NullLogger<PostgresIssuerAuditSink>.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
_issuerId = await SeedIssuerAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_PersistsAuditEntry()
|
||||
{
|
||||
var entry = CreateAuditEntry("issuer.created", "Issuer was created");
|
||||
|
||||
await _auditSink.WriteAsync(entry, CancellationToken.None);
|
||||
|
||||
var persisted = await ReadAuditEntryAsync(entry.TenantId, entry.IssuerId);
|
||||
persisted.Should().NotBeNull();
|
||||
persisted!.Action.Should().Be("issuer.created");
|
||||
persisted.Reason.Should().Be("Issuer was created");
|
||||
persisted.Actor.Should().Be("test@test.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_PersistsMetadata()
|
||||
{
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["oldSlug"] = "old-issuer",
|
||||
["newSlug"] = "new-issuer"
|
||||
};
|
||||
var entry = CreateAuditEntry("issuer.slug.changed", "Slug updated", metadata);
|
||||
|
||||
await _auditSink.WriteAsync(entry, CancellationToken.None);
|
||||
|
||||
var persisted = await ReadAuditEntryAsync(entry.TenantId, entry.IssuerId);
|
||||
persisted.Should().NotBeNull();
|
||||
persisted!.Details.Should().ContainKey("oldSlug");
|
||||
persisted.Details["oldSlug"].Should().Be("old-issuer");
|
||||
persisted.Details.Should().ContainKey("newSlug");
|
||||
persisted.Details["newSlug"].Should().Be("new-issuer");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_PersistsEmptyMetadata()
|
||||
{
|
||||
var entry = CreateAuditEntry("issuer.deleted", "Issuer removed");
|
||||
|
||||
await _auditSink.WriteAsync(entry, CancellationToken.None);
|
||||
|
||||
var persisted = await ReadAuditEntryAsync(entry.TenantId, entry.IssuerId);
|
||||
persisted.Should().NotBeNull();
|
||||
persisted!.Details.Should().NotBeNull();
|
||||
persisted.Details.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_PersistsNullReason()
|
||||
{
|
||||
var entry = CreateAuditEntry("issuer.updated", null);
|
||||
|
||||
await _auditSink.WriteAsync(entry, CancellationToken.None);
|
||||
|
||||
var persisted = await ReadAuditEntryAsync(entry.TenantId, entry.IssuerId);
|
||||
persisted.Should().NotBeNull();
|
||||
persisted!.Reason.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_PersistsTimestampCorrectly()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var entry = CreateAuditEntry("issuer.key.added", "Key added", timestamp: now);
|
||||
|
||||
await _auditSink.WriteAsync(entry, CancellationToken.None);
|
||||
|
||||
var persisted = await ReadAuditEntryAsync(entry.TenantId, entry.IssuerId);
|
||||
persisted.Should().NotBeNull();
|
||||
persisted!.OccurredAt.Should().BeCloseTo(now.UtcDateTime, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_PersistsMultipleEntriesForSameIssuer()
|
||||
{
|
||||
var entry1 = CreateAuditEntry("issuer.created", "Created");
|
||||
var entry2 = CreateAuditEntry("issuer.updated", "Updated");
|
||||
var entry3 = CreateAuditEntry("issuer.key.added", "Key added");
|
||||
|
||||
await _auditSink.WriteAsync(entry1, CancellationToken.None);
|
||||
await _auditSink.WriteAsync(entry2, CancellationToken.None);
|
||||
await _auditSink.WriteAsync(entry3, CancellationToken.None);
|
||||
|
||||
var count = await CountAuditEntriesAsync(_tenantId, _issuerId);
|
||||
count.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_PersistsActorCorrectly()
|
||||
{
|
||||
var entry = new IssuerAuditEntry(
|
||||
_tenantId,
|
||||
_issuerId,
|
||||
"issuer.trust.changed",
|
||||
DateTimeOffset.UtcNow,
|
||||
"admin@company.com",
|
||||
"Trust level modified",
|
||||
null);
|
||||
|
||||
await _auditSink.WriteAsync(entry, CancellationToken.None);
|
||||
|
||||
var persisted = await ReadAuditEntryAsync(entry.TenantId, entry.IssuerId);
|
||||
persisted.Should().NotBeNull();
|
||||
persisted!.Actor.Should().Be("admin@company.com");
|
||||
}
|
||||
|
||||
private async Task<string> SeedIssuerAsync()
|
||||
{
|
||||
var issuerId = Guid.NewGuid().ToString();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var issuer = new IssuerRecord
|
||||
{
|
||||
Id = issuerId,
|
||||
TenantId = _tenantId,
|
||||
Slug = $"test-issuer-{Guid.NewGuid():N}",
|
||||
DisplayName = "Test Issuer",
|
||||
Description = "Test issuer for audit tests",
|
||||
Contact = new IssuerContact(null, null, null, null),
|
||||
Metadata = new IssuerMetadata(null, null, null, null, [], new Dictionary<string, string>()),
|
||||
Endpoints = [],
|
||||
Tags = [],
|
||||
IsSystemSeed = false,
|
||||
CreatedAtUtc = now,
|
||||
CreatedBy = "test@test.com",
|
||||
UpdatedAtUtc = now,
|
||||
UpdatedBy = "test@test.com"
|
||||
};
|
||||
await _issuerRepository.UpsertAsync(issuer, CancellationToken.None);
|
||||
return issuerId;
|
||||
}
|
||||
|
||||
private IssuerAuditEntry CreateAuditEntry(
|
||||
string action,
|
||||
string? reason,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
DateTimeOffset? timestamp = null)
|
||||
{
|
||||
return new IssuerAuditEntry(
|
||||
_tenantId,
|
||||
_issuerId,
|
||||
action,
|
||||
timestamp ?? DateTimeOffset.UtcNow,
|
||||
"test@test.com",
|
||||
reason,
|
||||
metadata);
|
||||
}
|
||||
|
||||
private async Task<AuditEntryDto?> ReadAuditEntryAsync(string tenantId, string issuerId)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", CancellationToken.None);
|
||||
|
||||
const string sql = """
|
||||
SELECT actor, action, reason, details, occurred_at
|
||||
FROM issuer.audit
|
||||
WHERE tenant_id = @tenantId::uuid AND issuer_id = @issuerId::uuid
|
||||
ORDER BY occurred_at DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.Parameters.AddWithValue("tenantId", Guid.Parse(tenantId));
|
||||
command.Parameters.AddWithValue("issuerId", Guid.Parse(issuerId));
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
if (!await reader.ReadAsync())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var detailsJson = reader.GetString(3);
|
||||
var details = JsonSerializer.Deserialize<Dictionary<string, string>>(detailsJson) ?? [];
|
||||
|
||||
return new AuditEntryDto(
|
||||
reader.GetString(0),
|
||||
reader.GetString(1),
|
||||
reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
details,
|
||||
reader.GetDateTime(4));
|
||||
}
|
||||
|
||||
private async Task<int> CountAuditEntriesAsync(string tenantId, string issuerId)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", CancellationToken.None);
|
||||
|
||||
const string sql = """
|
||||
SELECT COUNT(*)
|
||||
FROM issuer.audit
|
||||
WHERE tenant_id = @tenantId::uuid AND issuer_id = @issuerId::uuid
|
||||
""";
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.Parameters.AddWithValue("tenantId", Guid.Parse(tenantId));
|
||||
command.Parameters.AddWithValue("issuerId", Guid.Parse(issuerId));
|
||||
|
||||
var result = await command.ExecuteScalarAsync();
|
||||
return Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
private sealed record AuditEntryDto(
|
||||
string Actor,
|
||||
string Action,
|
||||
string? Reason,
|
||||
Dictionary<string, string> Details,
|
||||
DateTime OccurredAt);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Reflection;
|
||||
using StellaOps.IssuerDirectory.Storage.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Storage.Postgres.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL integration test fixture for the IssuerDirectory module.
|
||||
/// Runs migrations from embedded resources and provides test isolation.
|
||||
/// </summary>
|
||||
public sealed class IssuerDirectoryPostgresFixture : PostgresIntegrationFixture, ICollectionFixture<IssuerDirectoryPostgresFixture>
|
||||
{
|
||||
protected override Assembly? GetMigrationAssembly()
|
||||
=> typeof(IssuerDirectoryDataSource).Assembly;
|
||||
|
||||
protected override string GetModuleName() => "IssuerDirectory";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for IssuerDirectory PostgreSQL integration tests.
|
||||
/// Tests in this collection share a single PostgreSQL container instance.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class IssuerDirectoryPostgresCollection : ICollectionFixture<IssuerDirectoryPostgresFixture>
|
||||
{
|
||||
public const string Name = "IssuerDirectoryPostgres";
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using StellaOps.IssuerDirectory.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Storage.Postgres.Tests;
|
||||
|
||||
[Collection(IssuerDirectoryPostgresCollection.Name)]
|
||||
public sealed class IssuerKeyRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly IssuerDirectoryPostgresFixture _fixture;
|
||||
private readonly PostgresIssuerRepository _issuerRepository;
|
||||
private readonly PostgresIssuerKeyRepository _keyRepository;
|
||||
private readonly string _tenantId = Guid.NewGuid().ToString();
|
||||
private string _issuerId = null!;
|
||||
|
||||
public IssuerKeyRepositoryTests(IssuerDirectoryPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
|
||||
var options = new PostgresOptions
|
||||
{
|
||||
ConnectionString = fixture.ConnectionString,
|
||||
SchemaName = fixture.SchemaName
|
||||
};
|
||||
var dataSource = new IssuerDirectoryDataSource(options, NullLogger<IssuerDirectoryDataSource>.Instance);
|
||||
_issuerRepository = new PostgresIssuerRepository(dataSource, NullLogger<PostgresIssuerRepository>.Instance);
|
||||
_keyRepository = new PostgresIssuerKeyRepository(dataSource, NullLogger<PostgresIssuerKeyRepository>.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
_issuerId = await SeedIssuerAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_CreatesNewKey()
|
||||
{
|
||||
var keyRecord = CreateKeyRecord("key-001", IssuerKeyType.Ed25519PublicKey);
|
||||
|
||||
await _keyRepository.UpsertAsync(keyRecord, CancellationToken.None);
|
||||
var fetched = await _keyRepository.GetAsync(_tenantId, _issuerId, "key-001", CancellationToken.None);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Id.Should().Be("key-001");
|
||||
fetched.Type.Should().Be(IssuerKeyType.Ed25519PublicKey);
|
||||
fetched.Status.Should().Be(IssuerKeyStatus.Active);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_UpdatesExistingKey()
|
||||
{
|
||||
var keyRecord = CreateKeyRecord("key-update", IssuerKeyType.Ed25519PublicKey);
|
||||
await _keyRepository.UpsertAsync(keyRecord, CancellationToken.None);
|
||||
|
||||
var updated = keyRecord with
|
||||
{
|
||||
Status = IssuerKeyStatus.Retired,
|
||||
RetiredAtUtc = DateTimeOffset.UtcNow,
|
||||
UpdatedAtUtc = DateTimeOffset.UtcNow,
|
||||
UpdatedBy = "admin@test.com"
|
||||
};
|
||||
|
||||
await _keyRepository.UpsertAsync(updated, CancellationToken.None);
|
||||
var fetched = await _keyRepository.GetAsync(_tenantId, _issuerId, "key-update", CancellationToken.None);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Status.Should().Be(IssuerKeyStatus.Retired);
|
||||
fetched.RetiredAtUtc.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_ReturnsNullForNonExistentKey()
|
||||
{
|
||||
var result = await _keyRepository.GetAsync(_tenantId, _issuerId, "nonexistent", CancellationToken.None);
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByFingerprintAsync_ReturnsKey()
|
||||
{
|
||||
var fingerprint = $"fp_{Guid.NewGuid():N}";
|
||||
var keyRecord = CreateKeyRecord("key-fp", IssuerKeyType.Ed25519PublicKey) with { Fingerprint = fingerprint };
|
||||
await _keyRepository.UpsertAsync(keyRecord, CancellationToken.None);
|
||||
|
||||
var fetched = await _keyRepository.GetByFingerprintAsync(_tenantId, _issuerId, fingerprint, CancellationToken.None);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Fingerprint.Should().Be(fingerprint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_ReturnsAllKeysForIssuer()
|
||||
{
|
||||
var key1 = CreateKeyRecord("key-list-1", IssuerKeyType.Ed25519PublicKey);
|
||||
var key2 = CreateKeyRecord("key-list-2", IssuerKeyType.X509Certificate);
|
||||
|
||||
await _keyRepository.UpsertAsync(key1, CancellationToken.None);
|
||||
await _keyRepository.UpsertAsync(key2, CancellationToken.None);
|
||||
|
||||
var results = await _keyRepository.ListAsync(_tenantId, _issuerId, CancellationToken.None);
|
||||
|
||||
results.Should().HaveCount(2);
|
||||
results.Select(k => k.Id).Should().BeEquivalentTo(["key-list-1", "key-list-2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListGlobalAsync_ReturnsGlobalKeys()
|
||||
{
|
||||
var globalIssuerId = await SeedGlobalIssuerAsync();
|
||||
var globalKey = CreateKeyRecord("global-key", IssuerKeyType.Ed25519PublicKey, globalIssuerId, IssuerTenants.Global);
|
||||
await _keyRepository.UpsertAsync(globalKey, CancellationToken.None);
|
||||
|
||||
var results = await _keyRepository.ListGlobalAsync(globalIssuerId, CancellationToken.None);
|
||||
|
||||
results.Should().Contain(k => k.Id == "global-key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_PersistsKeyTypeEd25519()
|
||||
{
|
||||
var keyRecord = CreateKeyRecord("key-ed25519", IssuerKeyType.Ed25519PublicKey);
|
||||
await _keyRepository.UpsertAsync(keyRecord, CancellationToken.None);
|
||||
|
||||
var fetched = await _keyRepository.GetAsync(_tenantId, _issuerId, "key-ed25519", CancellationToken.None);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Type.Should().Be(IssuerKeyType.Ed25519PublicKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_PersistsKeyTypeX509()
|
||||
{
|
||||
var keyRecord = CreateKeyRecord("key-x509", IssuerKeyType.X509Certificate);
|
||||
await _keyRepository.UpsertAsync(keyRecord, CancellationToken.None);
|
||||
|
||||
var fetched = await _keyRepository.GetAsync(_tenantId, _issuerId, "key-x509", CancellationToken.None);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Type.Should().Be(IssuerKeyType.X509Certificate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_PersistsKeyTypeDsse()
|
||||
{
|
||||
var keyRecord = CreateKeyRecord("key-dsse", IssuerKeyType.DssePublicKey);
|
||||
await _keyRepository.UpsertAsync(keyRecord, CancellationToken.None);
|
||||
|
||||
var fetched = await _keyRepository.GetAsync(_tenantId, _issuerId, "key-dsse", CancellationToken.None);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Type.Should().Be(IssuerKeyType.DssePublicKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_PersistsRevokedStatus()
|
||||
{
|
||||
var keyRecord = CreateKeyRecord("key-revoked", IssuerKeyType.Ed25519PublicKey) with
|
||||
{
|
||||
Status = IssuerKeyStatus.Revoked,
|
||||
RevokedAtUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
await _keyRepository.UpsertAsync(keyRecord, CancellationToken.None);
|
||||
|
||||
var fetched = await _keyRepository.GetAsync(_tenantId, _issuerId, "key-revoked", CancellationToken.None);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Status.Should().Be(IssuerKeyStatus.Revoked);
|
||||
fetched.RevokedAtUtc.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_PersistsReplacesKeyId()
|
||||
{
|
||||
var oldKey = CreateKeyRecord("old-key", IssuerKeyType.Ed25519PublicKey) with
|
||||
{
|
||||
Status = IssuerKeyStatus.Retired,
|
||||
RetiredAtUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
await _keyRepository.UpsertAsync(oldKey, CancellationToken.None);
|
||||
|
||||
var newKey = CreateKeyRecord("new-key", IssuerKeyType.Ed25519PublicKey) with
|
||||
{
|
||||
ReplacesKeyId = "old-key"
|
||||
};
|
||||
await _keyRepository.UpsertAsync(newKey, CancellationToken.None);
|
||||
|
||||
var fetched = await _keyRepository.GetAsync(_tenantId, _issuerId, "new-key", CancellationToken.None);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.ReplacesKeyId.Should().Be("old-key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_PersistsExpirationDate()
|
||||
{
|
||||
var expiresAt = DateTimeOffset.UtcNow.AddYears(1);
|
||||
var keyRecord = CreateKeyRecord("key-expires", IssuerKeyType.Ed25519PublicKey) with
|
||||
{
|
||||
ExpiresAtUtc = expiresAt
|
||||
};
|
||||
await _keyRepository.UpsertAsync(keyRecord, CancellationToken.None);
|
||||
|
||||
var fetched = await _keyRepository.GetAsync(_tenantId, _issuerId, "key-expires", CancellationToken.None);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.ExpiresAtUtc.Should().BeCloseTo(expiresAt, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
private async Task<string> SeedIssuerAsync()
|
||||
{
|
||||
var issuerId = Guid.NewGuid().ToString();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var issuer = new IssuerRecord
|
||||
{
|
||||
Id = issuerId,
|
||||
TenantId = _tenantId,
|
||||
Slug = $"test-issuer-{Guid.NewGuid():N}",
|
||||
DisplayName = "Test Issuer",
|
||||
Description = "Test issuer for key tests",
|
||||
Contact = new IssuerContact(null, null, null, null),
|
||||
Metadata = new IssuerMetadata(null, null, null, null, [], new Dictionary<string, string>()),
|
||||
Endpoints = [],
|
||||
Tags = [],
|
||||
IsSystemSeed = false,
|
||||
CreatedAtUtc = now,
|
||||
CreatedBy = "test@test.com",
|
||||
UpdatedAtUtc = now,
|
||||
UpdatedBy = "test@test.com"
|
||||
};
|
||||
await _issuerRepository.UpsertAsync(issuer, CancellationToken.None);
|
||||
return issuerId;
|
||||
}
|
||||
|
||||
private async Task<string> SeedGlobalIssuerAsync()
|
||||
{
|
||||
var issuerId = Guid.NewGuid().ToString();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var issuer = new IssuerRecord
|
||||
{
|
||||
Id = issuerId,
|
||||
TenantId = IssuerTenants.Global,
|
||||
Slug = $"global-issuer-{Guid.NewGuid():N}",
|
||||
DisplayName = "Global Test Issuer",
|
||||
Description = "Global test issuer",
|
||||
Contact = new IssuerContact(null, null, null, null),
|
||||
Metadata = new IssuerMetadata(null, null, null, null, [], new Dictionary<string, string>()),
|
||||
Endpoints = [],
|
||||
Tags = [],
|
||||
IsSystemSeed = true,
|
||||
CreatedAtUtc = now,
|
||||
CreatedBy = "system",
|
||||
UpdatedAtUtc = now,
|
||||
UpdatedBy = "system"
|
||||
};
|
||||
await _issuerRepository.UpsertAsync(issuer, CancellationToken.None);
|
||||
return issuerId;
|
||||
}
|
||||
|
||||
private IssuerKeyRecord CreateKeyRecord(string keyId, IssuerKeyType type, string? issuerId = null, string? tenantId = null)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return new IssuerKeyRecord
|
||||
{
|
||||
Id = keyId,
|
||||
IssuerId = issuerId ?? _issuerId,
|
||||
TenantId = tenantId ?? _tenantId,
|
||||
Type = type,
|
||||
Status = IssuerKeyStatus.Active,
|
||||
Material = new IssuerKeyMaterial("pem", $"-----BEGIN PUBLIC KEY-----\nMFkwE...\n-----END PUBLIC KEY-----"),
|
||||
Fingerprint = $"fp_{Guid.NewGuid():N}",
|
||||
CreatedAtUtc = now,
|
||||
CreatedBy = "test@test.com",
|
||||
UpdatedAtUtc = now,
|
||||
UpdatedBy = "test@test.com",
|
||||
ExpiresAtUtc = null,
|
||||
RetiredAtUtc = null,
|
||||
RevokedAtUtc = null,
|
||||
ReplacesKeyId = null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using StellaOps.IssuerDirectory.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Storage.Postgres.Tests;
|
||||
|
||||
[Collection(IssuerDirectoryPostgresCollection.Name)]
|
||||
public sealed class IssuerRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly IssuerDirectoryPostgresFixture _fixture;
|
||||
private readonly PostgresIssuerRepository _repository;
|
||||
private readonly string _tenantId = Guid.NewGuid().ToString();
|
||||
|
||||
public IssuerRepositoryTests(IssuerDirectoryPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
|
||||
var options = new PostgresOptions
|
||||
{
|
||||
ConnectionString = fixture.ConnectionString,
|
||||
SchemaName = fixture.SchemaName
|
||||
};
|
||||
var dataSource = new IssuerDirectoryDataSource(options, NullLogger<IssuerDirectoryDataSource>.Instance);
|
||||
_repository = new PostgresIssuerRepository(dataSource, NullLogger<PostgresIssuerRepository>.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_CreatesNewIssuer()
|
||||
{
|
||||
var record = CreateIssuerRecord("test-issuer", "Test Issuer");
|
||||
|
||||
await _repository.UpsertAsync(record, CancellationToken.None);
|
||||
var fetched = await _repository.GetAsync(_tenantId, record.Id, CancellationToken.None);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Id.Should().Be(record.Id);
|
||||
fetched.Slug.Should().Be("test-issuer");
|
||||
fetched.DisplayName.Should().Be("Test Issuer");
|
||||
fetched.TenantId.Should().Be(_tenantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_UpdatesExistingIssuer()
|
||||
{
|
||||
var record = CreateIssuerRecord("update-test", "Original Name");
|
||||
await _repository.UpsertAsync(record, CancellationToken.None);
|
||||
|
||||
var updated = record with
|
||||
{
|
||||
DisplayName = "Updated Name",
|
||||
Description = "Updated description",
|
||||
UpdatedAtUtc = DateTimeOffset.UtcNow,
|
||||
UpdatedBy = "updater@test.com"
|
||||
};
|
||||
|
||||
await _repository.UpsertAsync(updated, CancellationToken.None);
|
||||
var fetched = await _repository.GetAsync(_tenantId, record.Id, CancellationToken.None);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.DisplayName.Should().Be("Updated Name");
|
||||
fetched.Description.Should().Be("Updated description");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_ReturnsNullForNonExistentIssuer()
|
||||
{
|
||||
var result = await _repository.GetAsync(_tenantId, Guid.NewGuid().ToString(), CancellationToken.None);
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_ReturnsAllIssuersForTenant()
|
||||
{
|
||||
var issuer1 = CreateIssuerRecord("issuer-a", "Issuer A");
|
||||
var issuer2 = CreateIssuerRecord("issuer-b", "Issuer B");
|
||||
|
||||
await _repository.UpsertAsync(issuer1, CancellationToken.None);
|
||||
await _repository.UpsertAsync(issuer2, CancellationToken.None);
|
||||
|
||||
var results = await _repository.ListAsync(_tenantId, CancellationToken.None);
|
||||
|
||||
results.Should().HaveCount(2);
|
||||
results.Select(i => i.Slug).Should().BeEquivalentTo(["issuer-a", "issuer-b"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListGlobalAsync_ReturnsGlobalIssuers()
|
||||
{
|
||||
var globalIssuer = CreateIssuerRecord("global-issuer", "Global Issuer", IssuerTenants.Global);
|
||||
await _repository.UpsertAsync(globalIssuer, CancellationToken.None);
|
||||
|
||||
var results = await _repository.ListGlobalAsync(CancellationToken.None);
|
||||
|
||||
results.Should().Contain(i => i.Slug == "global-issuer");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_RemovesIssuer()
|
||||
{
|
||||
var record = CreateIssuerRecord("to-delete", "To Delete");
|
||||
await _repository.UpsertAsync(record, CancellationToken.None);
|
||||
|
||||
await _repository.DeleteAsync(_tenantId, record.Id, CancellationToken.None);
|
||||
var fetched = await _repository.GetAsync(_tenantId, record.Id, CancellationToken.None);
|
||||
|
||||
fetched.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_PersistsContactInformation()
|
||||
{
|
||||
var contact = new IssuerContact(
|
||||
"security@example.com",
|
||||
"+1-555-0100",
|
||||
new Uri("https://example.com/security"),
|
||||
"UTC");
|
||||
|
||||
var record = CreateIssuerRecord("contact-test", "Contact Test") with { Contact = contact };
|
||||
|
||||
await _repository.UpsertAsync(record, CancellationToken.None);
|
||||
var fetched = await _repository.GetAsync(_tenantId, record.Id, CancellationToken.None);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Contact.Email.Should().Be("security@example.com");
|
||||
fetched.Contact.Phone.Should().Be("+1-555-0100");
|
||||
fetched.Contact.Website.Should().Be(new Uri("https://example.com/security"));
|
||||
fetched.Contact.Timezone.Should().Be("UTC");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_PersistsEndpoints()
|
||||
{
|
||||
var endpoints = new List<IssuerEndpoint>
|
||||
{
|
||||
new("csaf", new Uri("https://example.com/.well-known/csaf/provider-metadata.json"), "json", false),
|
||||
new("oidc", new Uri("https://example.com/.well-known/openid-configuration"), "json", true)
|
||||
};
|
||||
|
||||
var record = CreateIssuerRecord("endpoints-test", "Endpoints Test") with { Endpoints = endpoints };
|
||||
|
||||
await _repository.UpsertAsync(record, CancellationToken.None);
|
||||
var fetched = await _repository.GetAsync(_tenantId, record.Id, CancellationToken.None);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Endpoints.Should().HaveCount(2);
|
||||
fetched.Endpoints.Should().Contain(e => e.Kind == "csaf");
|
||||
fetched.Endpoints.Should().Contain(e => e.Kind == "oidc" && e.RequiresAuthentication);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_PersistsMetadata()
|
||||
{
|
||||
var metadata = new IssuerMetadata(
|
||||
"CVE-2024-0001",
|
||||
"csaf-pub-123",
|
||||
new Uri("https://example.com/security-advisories"),
|
||||
new Uri("https://example.com/catalog"),
|
||||
["en", "de"],
|
||||
new Dictionary<string, string> { ["custom"] = "value" });
|
||||
|
||||
var record = CreateIssuerRecord("metadata-test", "Metadata Test") with { Metadata = metadata };
|
||||
|
||||
await _repository.UpsertAsync(record, CancellationToken.None);
|
||||
var fetched = await _repository.GetAsync(_tenantId, record.Id, CancellationToken.None);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Metadata.CveOrgId.Should().Be("CVE-2024-0001");
|
||||
fetched.Metadata.CsafPublisherId.Should().Be("csaf-pub-123");
|
||||
fetched.Metadata.SupportedLanguages.Should().BeEquivalentTo(["en", "de"]);
|
||||
fetched.Metadata.Attributes.Should().ContainKey("custom");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_PersistsTags()
|
||||
{
|
||||
var record = CreateIssuerRecord("tags-test", "Tags Test") with
|
||||
{
|
||||
Tags = ["vendor", "upstream", "critical"]
|
||||
};
|
||||
|
||||
await _repository.UpsertAsync(record, CancellationToken.None);
|
||||
var fetched = await _repository.GetAsync(_tenantId, record.Id, CancellationToken.None);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Tags.Should().BeEquivalentTo(["vendor", "upstream", "critical"]);
|
||||
}
|
||||
|
||||
private IssuerRecord CreateIssuerRecord(string slug, string displayName, string? tenantId = null)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return new IssuerRecord
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
TenantId = tenantId ?? _tenantId,
|
||||
Slug = slug,
|
||||
DisplayName = displayName,
|
||||
Description = $"Test issuer: {displayName}",
|
||||
Contact = new IssuerContact(null, null, null, null),
|
||||
Metadata = new IssuerMetadata(null, null, null, null, [], new Dictionary<string, string>()),
|
||||
Endpoints = [],
|
||||
Tags = [],
|
||||
IsSystemSeed = false,
|
||||
CreatedAtUtc = now,
|
||||
CreatedBy = "test@test.com",
|
||||
UpdatedAtUtc = now,
|
||||
UpdatedBy = "test@test.com"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using StellaOps.IssuerDirectory.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Storage.Postgres.Tests;
|
||||
|
||||
[Collection(IssuerDirectoryPostgresCollection.Name)]
|
||||
public sealed class IssuerTrustRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly IssuerDirectoryPostgresFixture _fixture;
|
||||
private readonly PostgresIssuerRepository _issuerRepository;
|
||||
private readonly PostgresIssuerTrustRepository _trustRepository;
|
||||
private readonly string _tenantId = Guid.NewGuid().ToString();
|
||||
private string _issuerId = null!;
|
||||
|
||||
public IssuerTrustRepositoryTests(IssuerDirectoryPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
|
||||
var options = new PostgresOptions
|
||||
{
|
||||
ConnectionString = fixture.ConnectionString,
|
||||
SchemaName = fixture.SchemaName
|
||||
};
|
||||
var dataSource = new IssuerDirectoryDataSource(options, NullLogger<IssuerDirectoryDataSource>.Instance);
|
||||
_issuerRepository = new PostgresIssuerRepository(dataSource, NullLogger<PostgresIssuerRepository>.Instance);
|
||||
_trustRepository = new PostgresIssuerTrustRepository(dataSource, NullLogger<PostgresIssuerTrustRepository>.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
_issuerId = await SeedIssuerAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_CreatesNewTrustOverride()
|
||||
{
|
||||
var record = CreateTrustRecord(5.5m, "Trusted vendor");
|
||||
|
||||
await _trustRepository.UpsertAsync(record, CancellationToken.None);
|
||||
var fetched = await _trustRepository.GetAsync(_tenantId, _issuerId, CancellationToken.None);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Weight.Should().Be(5.5m);
|
||||
fetched.Reason.Should().Be("Trusted vendor");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_UpdatesExistingTrustOverride()
|
||||
{
|
||||
var record = CreateTrustRecord(3.0m, "Initial trust");
|
||||
await _trustRepository.UpsertAsync(record, CancellationToken.None);
|
||||
|
||||
var updated = record.WithUpdated(7.5m, "Upgraded trust", DateTimeOffset.UtcNow, "admin@test.com");
|
||||
await _trustRepository.UpsertAsync(updated, CancellationToken.None);
|
||||
|
||||
var fetched = await _trustRepository.GetAsync(_tenantId, _issuerId, CancellationToken.None);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Weight.Should().Be(7.5m);
|
||||
fetched.Reason.Should().Be("Upgraded trust");
|
||||
fetched.UpdatedBy.Should().Be("admin@test.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_ReturnsNullForNonExistentOverride()
|
||||
{
|
||||
var result = await _trustRepository.GetAsync(_tenantId, Guid.NewGuid().ToString(), CancellationToken.None);
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_RemovesTrustOverride()
|
||||
{
|
||||
var record = CreateTrustRecord(2.0m, "To be deleted");
|
||||
await _trustRepository.UpsertAsync(record, CancellationToken.None);
|
||||
|
||||
await _trustRepository.DeleteAsync(_tenantId, _issuerId, CancellationToken.None);
|
||||
var fetched = await _trustRepository.GetAsync(_tenantId, _issuerId, CancellationToken.None);
|
||||
|
||||
fetched.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_PersistsPositiveWeight()
|
||||
{
|
||||
var record = CreateTrustRecord(10.0m, "Maximum trust");
|
||||
await _trustRepository.UpsertAsync(record, CancellationToken.None);
|
||||
|
||||
var fetched = await _trustRepository.GetAsync(_tenantId, _issuerId, CancellationToken.None);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Weight.Should().Be(10.0m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_PersistsNegativeWeight()
|
||||
{
|
||||
var record = CreateTrustRecord(-5.0m, "Distrust override");
|
||||
await _trustRepository.UpsertAsync(record, CancellationToken.None);
|
||||
|
||||
var fetched = await _trustRepository.GetAsync(_tenantId, _issuerId, CancellationToken.None);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Weight.Should().Be(-5.0m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_PersistsZeroWeight()
|
||||
{
|
||||
var record = CreateTrustRecord(0m, "Neutral trust");
|
||||
await _trustRepository.UpsertAsync(record, CancellationToken.None);
|
||||
|
||||
var fetched = await _trustRepository.GetAsync(_tenantId, _issuerId, CancellationToken.None);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Weight.Should().Be(0m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_PersistsNullReason()
|
||||
{
|
||||
var record = CreateTrustRecord(5.0m, null);
|
||||
await _trustRepository.UpsertAsync(record, CancellationToken.None);
|
||||
|
||||
var fetched = await _trustRepository.GetAsync(_tenantId, _issuerId, CancellationToken.None);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Reason.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_PersistsTimestamps()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var record = CreateTrustRecord(5.0m, "Time test");
|
||||
|
||||
await _trustRepository.UpsertAsync(record, CancellationToken.None);
|
||||
var fetched = await _trustRepository.GetAsync(_tenantId, _issuerId, CancellationToken.None);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.CreatedAtUtc.Should().BeCloseTo(now, TimeSpan.FromSeconds(5));
|
||||
fetched.UpdatedAtUtc.Should().BeCloseTo(now, TimeSpan.FromSeconds(5));
|
||||
fetched.CreatedBy.Should().Be("test@test.com");
|
||||
fetched.UpdatedBy.Should().Be("test@test.com");
|
||||
}
|
||||
|
||||
private async Task<string> SeedIssuerAsync()
|
||||
{
|
||||
var issuerId = Guid.NewGuid().ToString();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var issuer = new IssuerRecord
|
||||
{
|
||||
Id = issuerId,
|
||||
TenantId = _tenantId,
|
||||
Slug = $"test-issuer-{Guid.NewGuid():N}",
|
||||
DisplayName = "Test Issuer",
|
||||
Description = "Test issuer for trust tests",
|
||||
Contact = new IssuerContact(null, null, null, null),
|
||||
Metadata = new IssuerMetadata(null, null, null, null, [], new Dictionary<string, string>()),
|
||||
Endpoints = [],
|
||||
Tags = [],
|
||||
IsSystemSeed = false,
|
||||
CreatedAtUtc = now,
|
||||
CreatedBy = "test@test.com",
|
||||
UpdatedAtUtc = now,
|
||||
UpdatedBy = "test@test.com"
|
||||
};
|
||||
await _issuerRepository.UpsertAsync(issuer, CancellationToken.None);
|
||||
return issuerId;
|
||||
}
|
||||
|
||||
private IssuerTrustOverrideRecord CreateTrustRecord(decimal weight, string? reason)
|
||||
{
|
||||
return IssuerTrustOverrideRecord.Create(
|
||||
_issuerId,
|
||||
_tenantId,
|
||||
weight,
|
||||
reason,
|
||||
DateTimeOffset.UtcNow,
|
||||
"test@test.com");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<!-- Disable Concelier test infra since we use our own Postgres testing infra -->
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.IssuerDirectory.Storage.Postgres\StellaOps.IssuerDirectory.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.IssuerDirectory.Core\StellaOps.IssuerDirectory.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Infrastructure.Postgres.Testing;
|
||||
using StellaOps.IssuerDirectory.Storage.Postgres;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Storage.Postgres.Tests;
|
||||
|
||||
public sealed class IssuerDirectoryPostgresFixture : PostgresIntegrationFixture
|
||||
{
|
||||
protected override Assembly? GetMigrationAssembly() => typeof(IssuerDirectoryDataSource).Assembly;
|
||||
protected override string GetModuleName() => "issuer";
|
||||
protected override string? GetResourcePrefix() => "IssuerDirectory.Storage.Postgres.Migrations";
|
||||
protected override ILogger Logger => Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using StellaOps.IssuerDirectory.Storage.Postgres;
|
||||
using StellaOps.IssuerDirectory.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Storage.Postgres.Tests;
|
||||
|
||||
public class IssuerKeyRepositoryTests : IClassFixture<IssuerDirectoryPostgresFixture>
|
||||
{
|
||||
private readonly IssuerDirectoryPostgresFixture _fixture;
|
||||
|
||||
public IssuerKeyRepositoryTests(IssuerDirectoryPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
private PostgresIssuerRepository CreateIssuerRepo() =>
|
||||
new(new IssuerDirectoryDataSource(_fixture.Fixture.Options, NullLogger<IssuerDirectoryDataSource>.Instance),
|
||||
NullLogger<PostgresIssuerRepository>.Instance);
|
||||
|
||||
private PostgresIssuerKeyRepository CreateKeyRepo() =>
|
||||
new(new IssuerDirectoryDataSource(_fixture.Fixture.Options, NullLogger<IssuerDirectoryDataSource>.Instance),
|
||||
NullLogger<PostgresIssuerKeyRepository>.Instance);
|
||||
|
||||
[Fact]
|
||||
public async Task AddKey_And_List_Works()
|
||||
{
|
||||
var tenant = Guid.NewGuid().ToString();
|
||||
var issuerId = Guid.NewGuid().ToString();
|
||||
var issuerRepo = CreateIssuerRepo();
|
||||
var keyRepo = CreateKeyRepo();
|
||||
|
||||
var issuer = new IssuerRecord(
|
||||
issuerId,
|
||||
tenant,
|
||||
slug: "vendor-x",
|
||||
displayName: "Vendor X",
|
||||
description: null,
|
||||
endpoints: Array.Empty<IssuerEndpoint>(),
|
||||
contact: new IssuerContact(null, null),
|
||||
metadata: new IssuerMetadata(Array.Empty<string>(), null),
|
||||
tags: Array.Empty<string>(),
|
||||
status: "active",
|
||||
isSystemSeed: false,
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
createdBy: "test",
|
||||
updatedAt: DateTimeOffset.UtcNow,
|
||||
updatedBy: "test");
|
||||
await issuerRepo.UpsertAsync(issuer, CancellationToken.None);
|
||||
|
||||
var key = new IssuerKeyRecord(
|
||||
id: Guid.NewGuid().ToString(),
|
||||
issuerId: issuerId,
|
||||
keyId: "kid-1",
|
||||
keyType: IssuerKeyType.Ed25519,
|
||||
publicKey: "pubkey",
|
||||
fingerprint: "fp-1",
|
||||
notBefore: null,
|
||||
notAfter: null,
|
||||
status: IssuerKeyStatus.Active,
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
createdBy: "test",
|
||||
revokedAt: null,
|
||||
revokedBy: null,
|
||||
revokeReason: null,
|
||||
metadata: new IssuerKeyMetadata(null, null));
|
||||
|
||||
await keyRepo.UpsertAsync(key, CancellationToken.None);
|
||||
|
||||
var keys = await keyRepo.ListAsync(tenant, issuerId, CancellationToken.None);
|
||||
keys.Should().ContainSingle(k => k.KeyId == "kid-1" && k.IssuerId == issuerId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using StellaOps.IssuerDirectory.Storage.Postgres;
|
||||
using StellaOps.IssuerDirectory.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Storage.Postgres.Tests;
|
||||
|
||||
public class IssuerRepositoryTests : IClassFixture<IssuerDirectoryPostgresFixture>
|
||||
{
|
||||
private readonly IssuerDirectoryPostgresFixture _fixture;
|
||||
|
||||
public IssuerRepositoryTests(IssuerDirectoryPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
private PostgresIssuerRepository CreateRepository()
|
||||
{
|
||||
var dataSource = new IssuerDirectoryDataSource(
|
||||
_fixture.Fixture.Options,
|
||||
NullLogger<IssuerDirectoryDataSource>.Instance);
|
||||
return new PostgresIssuerRepository(dataSource, NullLogger<PostgresIssuerRepository>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAndGet_Works_For_Tenant()
|
||||
{
|
||||
var repo = CreateRepository();
|
||||
var tenant = Guid.NewGuid().ToString();
|
||||
var issuerId = Guid.NewGuid().ToString();
|
||||
var record = new IssuerRecord(
|
||||
issuerId,
|
||||
tenant,
|
||||
slug: "acme",
|
||||
displayName: "Acme Corp",
|
||||
description: "Test issuer",
|
||||
endpoints: new[] { new IssuerEndpoint("csaf", "https://acme.test/csaf") },
|
||||
contact: new IssuerContact("security@acme.test", null),
|
||||
metadata: new IssuerMetadata(Array.Empty<string>(), null),
|
||||
tags: new[] { "vendor", "csaf" },
|
||||
status: "active",
|
||||
isSystemSeed: false,
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
createdBy: "test",
|
||||
updatedAt: DateTimeOffset.UtcNow,
|
||||
updatedBy: "test");
|
||||
|
||||
await repo.UpsertAsync(record, CancellationToken.None);
|
||||
|
||||
var fetched = await repo.GetAsync(tenant, issuerId, CancellationToken.None);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Slug.Should().Be("acme");
|
||||
fetched.DisplayName.Should().Be("Acme Corp");
|
||||
fetched.Endpoints.Should().HaveCount(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\..\\StellaOps.IssuerDirectory\\StellaOps.IssuerDirectory.Storage.Postgres\\StellaOps.IssuerDirectory.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Infrastructure.Postgres.Testing\\StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
<ProjectReference Include="..\\..\\StellaOps.IssuerDirectory\\StellaOps.IssuerDirectory.Core\\StellaOps.IssuerDirectory.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,71 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.IssuerDirectory.Core.Domain;
|
||||
using StellaOps.IssuerDirectory.Storage.Postgres;
|
||||
using StellaOps.IssuerDirectory.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.IssuerDirectory.Storage.Postgres.Tests;
|
||||
|
||||
public class TrustRepositoryTests : IClassFixture<IssuerDirectoryPostgresFixture>
|
||||
{
|
||||
private readonly IssuerDirectoryPostgresFixture _fixture;
|
||||
|
||||
public TrustRepositoryTests(IssuerDirectoryPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
private PostgresIssuerRepository CreateIssuerRepo() =>
|
||||
new(new IssuerDirectoryDataSource(_fixture.Fixture.Options, NullLogger<IssuerDirectoryDataSource>.Instance),
|
||||
NullLogger<PostgresIssuerRepository>.Instance);
|
||||
|
||||
private PostgresIssuerTrustRepository CreateTrustRepo() =>
|
||||
new(new IssuerDirectoryDataSource(_fixture.Fixture.Options, NullLogger<IssuerDirectoryDataSource>.Instance),
|
||||
NullLogger<PostgresIssuerTrustRepository>.Instance);
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertTrustOverride_Works()
|
||||
{
|
||||
var tenant = Guid.NewGuid().ToString();
|
||||
var issuerId = Guid.NewGuid().ToString();
|
||||
var issuerRepo = CreateIssuerRepo();
|
||||
var trustRepo = CreateTrustRepo();
|
||||
|
||||
var issuer = new IssuerRecord(
|
||||
issuerId,
|
||||
tenant,
|
||||
slug: "trusty",
|
||||
displayName: "Trusty Issuer",
|
||||
description: null,
|
||||
endpoints: Array.Empty<IssuerEndpoint>(),
|
||||
contact: new IssuerContact(null, null),
|
||||
metadata: new IssuerMetadata(Array.Empty<string>(), null),
|
||||
tags: Array.Empty<string>(),
|
||||
status: "active",
|
||||
isSystemSeed: false,
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
createdBy: "test",
|
||||
updatedAt: DateTimeOffset.UtcNow,
|
||||
updatedBy: "test");
|
||||
await issuerRepo.UpsertAsync(issuer, CancellationToken.None);
|
||||
|
||||
var trust = new IssuerTrustOverrideRecord(
|
||||
id: Guid.NewGuid().ToString(),
|
||||
issuerId: issuerId,
|
||||
tenantId: tenant,
|
||||
weight: 0.75m,
|
||||
rationale: "vendor override",
|
||||
expiresAt: null,
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
createdBy: "test",
|
||||
updatedAt: DateTimeOffset.UtcNow,
|
||||
updatedBy: "test");
|
||||
|
||||
await trustRepo.UpsertAsync(trust, CancellationToken.None);
|
||||
|
||||
var fetched = await trustRepo.GetAsync(tenant, issuerId, CancellationToken.None);
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Weight.Should().Be(0.75m);
|
||||
}
|
||||
}
|
||||
@@ -16,5 +16,6 @@
|
||||
| TASKRUN-OBS-51-001 | DONE (2025-11-25) | SPRINT_0157_0001_0001_taskrunner_i | TASKRUN-OBS-50-001 | Metrics/SLOs; depends on 50-001. |
|
||||
| TASKRUN-OBS-52-001 | BLOCKED (2025-11-25) | SPRINT_0157_0001_0001_taskrunner_i | TASKRUN-OBS-51-001 | Timeline events; blocked: schema/evidence-pointer contract not published. |
|
||||
| TASKRUN-OBS-53-001 | BLOCKED (2025-11-25) | SPRINT_0157_0001_0001_taskrunner_i | TASKRUN-OBS-52-001 | Evidence locker snapshots; blocked: waiting on timeline schema/pointer contract. |
|
||||
| TASKRUN-GAPS-157-014 | DONE (2025-12-05) | SPRINT_0157_0001_0001_taskrunner_i | — | TP1–TP10 remediation: canonical plan-hash recipe, inputs.lock evidence, approval DSSE ledger, redaction, deterministic RNG/time, sandbox/egress quotas, registry signing + SBOM + revocation, offline bundle schema + verifier script, SLO/alerting, fail-closed gates. |
|
||||
|
||||
Status source of truth: `docs/implplan/SPRINT_0157_0001_0001_taskrunner_i.md`. Update both files together. Keep UTC dates when advancing status.
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Microservice.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="EndpointRegistry"/>.
|
||||
/// </summary>
|
||||
public sealed class EndpointRegistryTests
|
||||
{
|
||||
private static EndpointDescriptor CreateEndpoint(string method, string path)
|
||||
{
|
||||
return new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Method = method,
|
||||
Path = path
|
||||
};
|
||||
}
|
||||
|
||||
#region Register Tests
|
||||
|
||||
[Fact]
|
||||
public void Register_SingleEndpoint_AddsToRegistry()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
var endpoint = CreateEndpoint("GET", "/api/users");
|
||||
|
||||
// Act
|
||||
registry.Register(endpoint);
|
||||
|
||||
// Assert
|
||||
registry.GetAllEndpoints().Should().HaveCount(1);
|
||||
registry.GetAllEndpoints()[0].Should().Be(endpoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_MultipleEndpoints_AddsAllToRegistry()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
|
||||
// Act
|
||||
registry.Register(CreateEndpoint("GET", "/api/users"));
|
||||
registry.Register(CreateEndpoint("POST", "/api/users"));
|
||||
registry.Register(CreateEndpoint("GET", "/api/users/{id}"));
|
||||
|
||||
// Assert
|
||||
registry.GetAllEndpoints().Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterAll_AddsAllEndpoints()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
var endpoints = new[]
|
||||
{
|
||||
CreateEndpoint("GET", "/api/users"),
|
||||
CreateEndpoint("POST", "/api/users"),
|
||||
CreateEndpoint("DELETE", "/api/users/{id}")
|
||||
};
|
||||
|
||||
// Act
|
||||
registry.RegisterAll(endpoints);
|
||||
|
||||
// Assert
|
||||
registry.GetAllEndpoints().Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterAll_WithEmptyCollection_DoesNotAddAny()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
|
||||
// Act
|
||||
registry.RegisterAll([]);
|
||||
|
||||
// Assert
|
||||
registry.GetAllEndpoints().Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TryMatch Method Tests
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_ExactMethodAndPath_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
registry.Register(CreateEndpoint("GET", "/api/users"));
|
||||
|
||||
// Act
|
||||
var result = registry.TryMatch("GET", "/api/users", out var match);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
match.Should().NotBeNull();
|
||||
match!.Endpoint.Path.Should().Be("/api/users");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_NonMatchingMethod_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
registry.Register(CreateEndpoint("GET", "/api/users"));
|
||||
|
||||
// Act
|
||||
var result = registry.TryMatch("POST", "/api/users", out var match);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
match.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_NonMatchingPath_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
registry.Register(CreateEndpoint("GET", "/api/users"));
|
||||
|
||||
// Act
|
||||
var result = registry.TryMatch("GET", "/api/items", out var match);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
match.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_MethodIsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
registry.Register(CreateEndpoint("GET", "/api/users"));
|
||||
|
||||
// Act & Assert
|
||||
registry.TryMatch("get", "/api/users", out _).Should().BeTrue();
|
||||
registry.TryMatch("Get", "/api/users", out _).Should().BeTrue();
|
||||
registry.TryMatch("GET", "/api/users", out _).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_PathIsCaseInsensitive_WhenEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry(caseInsensitive: true);
|
||||
registry.Register(CreateEndpoint("GET", "/api/users"));
|
||||
|
||||
// Act & Assert
|
||||
registry.TryMatch("GET", "/API/USERS", out _).Should().BeTrue();
|
||||
registry.TryMatch("GET", "/Api/Users", out _).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_PathIsCaseSensitive_WhenDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry(caseInsensitive: false);
|
||||
registry.Register(CreateEndpoint("GET", "/api/users"));
|
||||
|
||||
// Act & Assert
|
||||
registry.TryMatch("GET", "/api/users", out _).Should().BeTrue();
|
||||
registry.TryMatch("GET", "/API/USERS", out _).Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TryMatch Path Parameter Tests
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_PathWithParameter_ExtractsParameter()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
registry.Register(CreateEndpoint("GET", "/api/users/{id}"));
|
||||
|
||||
// Act
|
||||
var result = registry.TryMatch("GET", "/api/users/123", out var match);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
match.Should().NotBeNull();
|
||||
match!.PathParameters.Should().ContainKey("id");
|
||||
match.PathParameters["id"].Should().Be("123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_PathWithMultipleParameters_ExtractsAll()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
registry.Register(CreateEndpoint("GET", "/api/users/{userId}/orders/{orderId}"));
|
||||
|
||||
// Act
|
||||
var result = registry.TryMatch("GET", "/api/users/456/orders/789", out var match);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
match.Should().NotBeNull();
|
||||
match!.PathParameters.Should().HaveCount(2);
|
||||
match.PathParameters["userId"].Should().Be("456");
|
||||
match.PathParameters["orderId"].Should().Be("789");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_PathParameterWithSpecialChars_ExtractsParameter()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
registry.Register(CreateEndpoint("GET", "/api/items/{itemId}"));
|
||||
|
||||
// Act
|
||||
var result = registry.TryMatch("GET", "/api/items/item-with-dashes", out var match);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
match!.PathParameters["itemId"].Should().Be("item-with-dashes");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_EmptyPathParameter_DoesNotMatch()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
registry.Register(CreateEndpoint("GET", "/api/users/{id}"));
|
||||
|
||||
// Act
|
||||
var result = registry.TryMatch("GET", "/api/users/", out var match);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TryMatch Multiple Endpoints Tests
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_FirstMatchingEndpoint_ReturnsFirst()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
registry.Register(CreateEndpoint("GET", "/api/users"));
|
||||
registry.Register(CreateEndpoint("GET", "/api/users")); // duplicate
|
||||
|
||||
// Act
|
||||
registry.TryMatch("GET", "/api/users", out var match);
|
||||
|
||||
// Assert - should return the first registered
|
||||
match.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_SelectsCorrectEndpointByMethod()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
registry.Register(new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Method = "GET",
|
||||
Path = "/api/users",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(10)
|
||||
});
|
||||
registry.Register(new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Method = "POST",
|
||||
Path = "/api/users",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(30)
|
||||
});
|
||||
|
||||
// Act
|
||||
registry.TryMatch("POST", "/api/users", out var match);
|
||||
|
||||
// Assert
|
||||
match.Should().NotBeNull();
|
||||
match!.Endpoint.Method.Should().Be("POST");
|
||||
match.Endpoint.DefaultTimeout.Should().Be(TimeSpan.FromSeconds(30));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetAllEndpoints Tests
|
||||
|
||||
[Fact]
|
||||
public void GetAllEndpoints_EmptyRegistry_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
|
||||
// Act
|
||||
var endpoints = registry.GetAllEndpoints();
|
||||
|
||||
// Assert
|
||||
endpoints.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllEndpoints_ReturnsAllRegisteredEndpoints()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
var endpoint1 = CreateEndpoint("GET", "/api/a");
|
||||
var endpoint2 = CreateEndpoint("POST", "/api/b");
|
||||
var endpoint3 = CreateEndpoint("DELETE", "/api/c");
|
||||
registry.RegisterAll([endpoint1, endpoint2, endpoint3]);
|
||||
|
||||
// Act
|
||||
var endpoints = registry.GetAllEndpoints();
|
||||
|
||||
// Assert
|
||||
endpoints.Should().HaveCount(3);
|
||||
endpoints.Should().Contain(endpoint1);
|
||||
endpoints.Should().Contain(endpoint2);
|
||||
endpoints.Should().Contain(endpoint3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllEndpoints_PreservesRegistrationOrder()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
var endpoint1 = CreateEndpoint("GET", "/first");
|
||||
var endpoint2 = CreateEndpoint("GET", "/second");
|
||||
var endpoint3 = CreateEndpoint("GET", "/third");
|
||||
registry.Register(endpoint1);
|
||||
registry.Register(endpoint2);
|
||||
registry.Register(endpoint3);
|
||||
|
||||
// Act
|
||||
var endpoints = registry.GetAllEndpoints();
|
||||
|
||||
// Assert
|
||||
endpoints[0].Should().Be(endpoint1);
|
||||
endpoints[1].Should().Be(endpoint2);
|
||||
endpoints[2].Should().Be(endpoint3);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DefaultCaseInsensitive_IsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry();
|
||||
registry.Register(CreateEndpoint("GET", "/api/Test"));
|
||||
|
||||
// Act & Assert - should match case-insensitively by default
|
||||
registry.TryMatch("GET", "/api/test", out _).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ExplicitCaseInsensitiveFalse_IsCaseSensitive()
|
||||
{
|
||||
// Arrange
|
||||
var registry = new EndpointRegistry(caseInsensitive: false);
|
||||
registry.Register(CreateEndpoint("GET", "/api/Test"));
|
||||
|
||||
// Act & Assert
|
||||
registry.TryMatch("GET", "/api/Test", out _).Should().BeTrue();
|
||||
registry.TryMatch("GET", "/api/test", out _).Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
namespace StellaOps.Microservice.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="HeaderCollection"/>.
|
||||
/// </summary>
|
||||
public sealed class HeaderCollectionTests
|
||||
{
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Default_CreatesEmptyCollection()
|
||||
{
|
||||
// Arrange & Act
|
||||
var headers = new HeaderCollection();
|
||||
|
||||
// Assert
|
||||
headers.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithKeyValuePairs_AddsAllHeaders()
|
||||
{
|
||||
// Arrange
|
||||
var pairs = new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("Content-Type", "application/json"),
|
||||
new KeyValuePair<string, string>("Accept", "application/json")
|
||||
};
|
||||
|
||||
// Act
|
||||
var headers = new HeaderCollection(pairs);
|
||||
|
||||
// Assert
|
||||
headers["Content-Type"].Should().Be("application/json");
|
||||
headers["Accept"].Should().Be("application/json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithDuplicateKeys_AddsMultipleValues()
|
||||
{
|
||||
// Arrange
|
||||
var pairs = new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("Accept", "application/json"),
|
||||
new KeyValuePair<string, string>("Accept", "text/plain")
|
||||
};
|
||||
|
||||
// Act
|
||||
var headers = new HeaderCollection(pairs);
|
||||
|
||||
// Assert
|
||||
headers.GetValues("Accept").Should().BeEquivalentTo(["application/json", "text/plain"]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Empty Tests
|
||||
|
||||
[Fact]
|
||||
public void Empty_IsSharedInstance()
|
||||
{
|
||||
// Arrange & Act
|
||||
var empty1 = HeaderCollection.Empty;
|
||||
var empty2 = HeaderCollection.Empty;
|
||||
|
||||
// Assert
|
||||
empty1.Should().BeSameAs(empty2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_HasNoHeaders()
|
||||
{
|
||||
// Arrange & Act
|
||||
var empty = HeaderCollection.Empty;
|
||||
|
||||
// Assert
|
||||
empty.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Indexer Tests
|
||||
|
||||
[Fact]
|
||||
public void Indexer_ExistingKey_ReturnsFirstValue()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("Content-Type", "application/json");
|
||||
|
||||
// Act
|
||||
var value = headers["Content-Type"];
|
||||
|
||||
// Assert
|
||||
value.Should().Be("application/json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indexer_MultipleValues_ReturnsFirstValue()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("Accept", "application/json");
|
||||
headers.Add("Accept", "text/plain");
|
||||
|
||||
// Act
|
||||
var value = headers["Accept"];
|
||||
|
||||
// Assert
|
||||
value.Should().Be("application/json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indexer_NonexistentKey_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
|
||||
// Act
|
||||
var value = headers["X-Missing"];
|
||||
|
||||
// Assert
|
||||
value.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indexer_IsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("Content-Type", "application/json");
|
||||
|
||||
// Act & Assert
|
||||
headers["content-type"].Should().Be("application/json");
|
||||
headers["CONTENT-TYPE"].Should().Be("application/json");
|
||||
headers["Content-TYPE"].Should().Be("application/json");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Add Tests
|
||||
|
||||
[Fact]
|
||||
public void Add_NewKey_AddsHeader()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
|
||||
// Act
|
||||
headers.Add("Content-Type", "application/json");
|
||||
|
||||
// Assert
|
||||
headers["Content-Type"].Should().Be("application/json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_ExistingKey_AppendsValue()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("Accept", "application/json");
|
||||
|
||||
// Act
|
||||
headers.Add("Accept", "text/plain");
|
||||
|
||||
// Assert
|
||||
headers.GetValues("Accept").Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_CaseInsensitiveKey_AppendsToExisting()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("Content-Type", "application/json");
|
||||
|
||||
// Act
|
||||
headers.Add("content-type", "text/plain");
|
||||
|
||||
// Assert
|
||||
headers.GetValues("Content-Type").Should().HaveCount(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Set Tests
|
||||
|
||||
[Fact]
|
||||
public void Set_NewKey_AddsHeader()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
|
||||
// Act
|
||||
headers.Set("Content-Type", "application/json");
|
||||
|
||||
// Assert
|
||||
headers["Content-Type"].Should().Be("application/json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Set_ExistingKey_ReplacesValue()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("Content-Type", "text/plain");
|
||||
headers.Add("Content-Type", "text/html");
|
||||
|
||||
// Act
|
||||
headers.Set("Content-Type", "application/json");
|
||||
|
||||
// Assert
|
||||
headers.GetValues("Content-Type").Should().BeEquivalentTo(["application/json"]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetValues Tests
|
||||
|
||||
[Fact]
|
||||
public void GetValues_ExistingKey_ReturnsAllValues()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("Accept", "application/json");
|
||||
headers.Add("Accept", "text/plain");
|
||||
headers.Add("Accept", "text/html");
|
||||
|
||||
// Act
|
||||
var values = headers.GetValues("Accept");
|
||||
|
||||
// Assert
|
||||
values.Should().BeEquivalentTo(["application/json", "text/plain", "text/html"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetValues_NonexistentKey_ReturnsEmptyEnumerable()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
|
||||
// Act
|
||||
var values = headers.GetValues("X-Missing");
|
||||
|
||||
// Assert
|
||||
values.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetValues_IsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("Accept", "application/json");
|
||||
|
||||
// Act & Assert
|
||||
headers.GetValues("accept").Should().Contain("application/json");
|
||||
headers.GetValues("ACCEPT").Should().Contain("application/json");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TryGetValue Tests
|
||||
|
||||
[Fact]
|
||||
public void TryGetValue_ExistingKey_ReturnsTrueAndValue()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("Content-Type", "application/json");
|
||||
|
||||
// Act
|
||||
var result = headers.TryGetValue("Content-Type", out var value);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
value.Should().Be("application/json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetValue_NonexistentKey_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
|
||||
// Act
|
||||
var result = headers.TryGetValue("X-Missing", out var value);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
value.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetValue_IsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("Content-Type", "application/json");
|
||||
|
||||
// Act
|
||||
var result = headers.TryGetValue("content-type", out var value);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
value.Should().Be("application/json");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ContainsKey Tests
|
||||
|
||||
[Fact]
|
||||
public void ContainsKey_ExistingKey_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("Content-Type", "application/json");
|
||||
|
||||
// Act & Assert
|
||||
headers.ContainsKey("Content-Type").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContainsKey_NonexistentKey_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
|
||||
// Act & Assert
|
||||
headers.ContainsKey("X-Missing").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContainsKey_IsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("Content-Type", "application/json");
|
||||
|
||||
// Act & Assert
|
||||
headers.ContainsKey("content-type").Should().BeTrue();
|
||||
headers.ContainsKey("CONTENT-TYPE").Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Enumeration Tests
|
||||
|
||||
[Fact]
|
||||
public void GetEnumerator_EnumeratesAllHeaderValues()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("Content-Type", "application/json");
|
||||
headers.Add("Accept", "text/plain");
|
||||
headers.Add("Accept", "text/html");
|
||||
|
||||
// Act
|
||||
var list = headers.ToList();
|
||||
|
||||
// Assert
|
||||
list.Should().HaveCount(3);
|
||||
list.Should().Contain(new KeyValuePair<string, string>("Content-Type", "application/json"));
|
||||
list.Should().Contain(new KeyValuePair<string, string>("Accept", "text/plain"));
|
||||
list.Should().Contain(new KeyValuePair<string, string>("Accept", "text/html"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEnumerator_EmptyCollection_EnumeratesNothing()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
|
||||
// Act
|
||||
var list = headers.ToList();
|
||||
|
||||
// Assert
|
||||
list.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace StellaOps.Microservice.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="InflightRequestTracker"/>.
|
||||
/// </summary>
|
||||
public sealed class InflightRequestTrackerTests : IDisposable
|
||||
{
|
||||
private readonly InflightRequestTracker _tracker;
|
||||
|
||||
public InflightRequestTrackerTests()
|
||||
{
|
||||
_tracker = new InflightRequestTracker(NullLogger<InflightRequestTracker>.Instance);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_tracker.Dispose();
|
||||
}
|
||||
|
||||
#region Track Tests
|
||||
|
||||
[Fact]
|
||||
public void Track_NewRequest_ReturnsNonCancelledToken()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
var token = _tracker.Track(correlationId);
|
||||
|
||||
// Assert
|
||||
token.IsCancellationRequested.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Track_NewRequest_IncreasesCount()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
_tracker.Track(correlationId);
|
||||
|
||||
// Assert
|
||||
_tracker.Count.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Track_MultipleRequests_TracksAll()
|
||||
{
|
||||
// Arrange & Act
|
||||
_tracker.Track(Guid.NewGuid());
|
||||
_tracker.Track(Guid.NewGuid());
|
||||
_tracker.Track(Guid.NewGuid());
|
||||
|
||||
// Assert
|
||||
_tracker.Count.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Track_DuplicateCorrelationId_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = Guid.NewGuid();
|
||||
_tracker.Track(correlationId);
|
||||
|
||||
// Act
|
||||
var action = () => _tracker.Track(correlationId);
|
||||
|
||||
// Assert
|
||||
action.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage($"*{correlationId}*already being tracked*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Track_AfterDispose_ThrowsObjectDisposedException()
|
||||
{
|
||||
// Arrange
|
||||
_tracker.Dispose();
|
||||
|
||||
// Act
|
||||
var action = () => _tracker.Track(Guid.NewGuid());
|
||||
|
||||
// Assert
|
||||
action.Should().Throw<ObjectDisposedException>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cancel Tests
|
||||
|
||||
[Fact]
|
||||
public void Cancel_TrackedRequest_CancelsToken()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = Guid.NewGuid();
|
||||
var token = _tracker.Track(correlationId);
|
||||
|
||||
// Act
|
||||
var result = _tracker.Cancel(correlationId, "Test cancellation");
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
token.IsCancellationRequested.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_UntrackedRequest_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
var result = _tracker.Cancel(correlationId, "Test cancellation");
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_WithNullReason_Works()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = Guid.NewGuid();
|
||||
_tracker.Track(correlationId);
|
||||
|
||||
// Act
|
||||
var result = _tracker.Cancel(correlationId, null);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_CompletedRequest_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = Guid.NewGuid();
|
||||
_tracker.Track(correlationId);
|
||||
_tracker.Complete(correlationId);
|
||||
|
||||
// Act
|
||||
var result = _tracker.Cancel(correlationId, "Test cancellation");
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Complete Tests
|
||||
|
||||
[Fact]
|
||||
public void Complete_TrackedRequest_RemovesFromTracking()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = Guid.NewGuid();
|
||||
_tracker.Track(correlationId);
|
||||
|
||||
// Act
|
||||
_tracker.Complete(correlationId);
|
||||
|
||||
// Assert
|
||||
_tracker.Count.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Complete_UntrackedRequest_DoesNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
var action = () => _tracker.Complete(correlationId);
|
||||
|
||||
// Assert
|
||||
action.Should().NotThrow();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Complete_MultipleCompletions_DoesNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
var correlationId = Guid.NewGuid();
|
||||
_tracker.Track(correlationId);
|
||||
|
||||
// Act
|
||||
var action = () =>
|
||||
{
|
||||
_tracker.Complete(correlationId);
|
||||
_tracker.Complete(correlationId);
|
||||
};
|
||||
|
||||
// Assert
|
||||
action.Should().NotThrow();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CancelAll Tests
|
||||
|
||||
[Fact]
|
||||
public void CancelAll_CancelsAllTrackedRequests()
|
||||
{
|
||||
// Arrange
|
||||
var token1 = _tracker.Track(Guid.NewGuid());
|
||||
var token2 = _tracker.Track(Guid.NewGuid());
|
||||
var token3 = _tracker.Track(Guid.NewGuid());
|
||||
|
||||
// Act
|
||||
_tracker.CancelAll("Shutdown");
|
||||
|
||||
// Assert
|
||||
token1.IsCancellationRequested.Should().BeTrue();
|
||||
token2.IsCancellationRequested.Should().BeTrue();
|
||||
token3.IsCancellationRequested.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancelAll_ClearsTrackedRequests()
|
||||
{
|
||||
// Arrange
|
||||
_tracker.Track(Guid.NewGuid());
|
||||
_tracker.Track(Guid.NewGuid());
|
||||
|
||||
// Act
|
||||
_tracker.CancelAll("Shutdown");
|
||||
|
||||
// Assert
|
||||
_tracker.Count.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancelAll_WithNoRequests_DoesNotThrow()
|
||||
{
|
||||
// Arrange & Act
|
||||
var action = () => _tracker.CancelAll("Test");
|
||||
|
||||
// Assert
|
||||
action.Should().NotThrow();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Dispose Tests
|
||||
|
||||
[Fact]
|
||||
public void Dispose_CancelsAllRequests()
|
||||
{
|
||||
// Arrange
|
||||
var token = _tracker.Track(Guid.NewGuid());
|
||||
|
||||
// Act
|
||||
_tracker.Dispose();
|
||||
|
||||
// Assert
|
||||
token.IsCancellationRequested.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_CanBeCalledMultipleTimes()
|
||||
{
|
||||
// Arrange & Act
|
||||
var action = () =>
|
||||
{
|
||||
_tracker.Dispose();
|
||||
_tracker.Dispose();
|
||||
_tracker.Dispose();
|
||||
};
|
||||
|
||||
// Assert
|
||||
action.Should().NotThrow();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Count Tests
|
||||
|
||||
[Fact]
|
||||
public void Count_InitiallyZero()
|
||||
{
|
||||
// Arrange - use a fresh tracker
|
||||
using var tracker = new InflightRequestTracker(NullLogger<InflightRequestTracker>.Instance);
|
||||
|
||||
// Assert
|
||||
tracker.Count.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Count_ReflectsActiveRequests()
|
||||
{
|
||||
// Arrange
|
||||
var id1 = Guid.NewGuid();
|
||||
var id2 = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
_tracker.Track(id1);
|
||||
_tracker.Track(id2);
|
||||
_tracker.Complete(id1);
|
||||
|
||||
// Assert
|
||||
_tracker.Count.Should().Be(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
namespace StellaOps.Microservice.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="RawRequestContext"/>.
|
||||
/// </summary>
|
||||
public sealed class RawRequestContextTests
|
||||
{
|
||||
#region Default Values Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Method_DefaultsToEmptyString()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = new RawRequestContext();
|
||||
|
||||
// Assert
|
||||
context.Method.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Path_DefaultsToEmptyString()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = new RawRequestContext();
|
||||
|
||||
// Assert
|
||||
context.Path.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_PathParameters_DefaultsToEmptyDictionary()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = new RawRequestContext();
|
||||
|
||||
// Assert
|
||||
context.PathParameters.Should().NotBeNull();
|
||||
context.PathParameters.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Headers_DefaultsToEmptyCollection()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = new RawRequestContext();
|
||||
|
||||
// Assert
|
||||
context.Headers.Should().BeSameAs(HeaderCollection.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Body_DefaultsToStreamNull()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = new RawRequestContext();
|
||||
|
||||
// Assert
|
||||
context.Body.Should().BeSameAs(Stream.Null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CancellationToken_DefaultsToNone()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = new RawRequestContext();
|
||||
|
||||
// Assert
|
||||
context.CancellationToken.Should().Be(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CorrelationId_DefaultsToNull()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = new RawRequestContext();
|
||||
|
||||
// Assert
|
||||
context.CorrelationId.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Property Initialization Tests
|
||||
|
||||
[Fact]
|
||||
public void Method_CanBeInitialized()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = new RawRequestContext { Method = "POST" };
|
||||
|
||||
// Assert
|
||||
context.Method.Should().Be("POST");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Path_CanBeInitialized()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = new RawRequestContext { Path = "/api/users/123" };
|
||||
|
||||
// Assert
|
||||
context.Path.Should().Be("/api/users/123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PathParameters_CanBeInitialized()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["id"] = "123",
|
||||
["action"] = "update"
|
||||
};
|
||||
|
||||
// Act
|
||||
var context = new RawRequestContext { PathParameters = parameters };
|
||||
|
||||
// Assert
|
||||
context.PathParameters.Should().HaveCount(2);
|
||||
context.PathParameters["id"].Should().Be("123");
|
||||
context.PathParameters["action"].Should().Be("update");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Headers_CanBeInitialized()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("Content-Type", "application/json");
|
||||
headers.Add("Authorization", "Bearer token");
|
||||
|
||||
// Act
|
||||
var context = new RawRequestContext { Headers = headers };
|
||||
|
||||
// Assert
|
||||
context.Headers["Content-Type"].Should().Be("application/json");
|
||||
context.Headers["Authorization"].Should().Be("Bearer token");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Body_CanBeInitialized()
|
||||
{
|
||||
// Arrange
|
||||
var body = new MemoryStream([1, 2, 3, 4, 5]);
|
||||
|
||||
// Act
|
||||
var context = new RawRequestContext { Body = body };
|
||||
|
||||
// Assert
|
||||
context.Body.Should().BeSameAs(body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancellationToken_CanBeInitialized()
|
||||
{
|
||||
// Arrange
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
var context = new RawRequestContext { CancellationToken = cts.Token };
|
||||
|
||||
// Assert
|
||||
context.CancellationToken.Should().Be(cts.Token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CorrelationId_CanBeInitialized()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = new RawRequestContext { CorrelationId = "req-12345" };
|
||||
|
||||
// Assert
|
||||
context.CorrelationId.Should().Be("req-12345");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Complete Context Tests
|
||||
|
||||
[Fact]
|
||||
public void CompleteContext_AllPropertiesSet_Works()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Add("Content-Type", "application/json");
|
||||
|
||||
var body = new MemoryStream([123, 125]); // "{}"
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
var context = new RawRequestContext
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/api/users/{id}",
|
||||
PathParameters = new Dictionary<string, string> { ["id"] = "456" },
|
||||
Headers = headers,
|
||||
Body = body,
|
||||
CancellationToken = cts.Token,
|
||||
CorrelationId = "corr-789"
|
||||
};
|
||||
|
||||
// Assert
|
||||
context.Method.Should().Be("POST");
|
||||
context.Path.Should().Be("/api/users/{id}");
|
||||
context.PathParameters["id"].Should().Be("456");
|
||||
context.Headers["Content-Type"].Should().Be("application/json");
|
||||
context.Body.Should().BeSameAs(body);
|
||||
context.CancellationToken.Should().Be(cts.Token);
|
||||
context.CorrelationId.Should().Be("corr-789");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Context_WithCancelledToken_HasCancellationRequested()
|
||||
{
|
||||
// Arrange
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
// Act
|
||||
var context = new RawRequestContext { CancellationToken = cts.Token };
|
||||
|
||||
// Assert
|
||||
context.CancellationToken.IsCancellationRequested.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Typical Use Case Tests
|
||||
|
||||
[Fact]
|
||||
public void TypicalGetRequest_HasMinimalProperties()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = new RawRequestContext
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/api/health"
|
||||
};
|
||||
|
||||
// Assert
|
||||
context.Method.Should().Be("GET");
|
||||
context.Path.Should().Be("/api/health");
|
||||
context.Body.Should().BeSameAs(Stream.Null);
|
||||
context.Headers.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TypicalPostRequest_HasBodyAndHeaders()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Set("Content-Type", "application/json");
|
||||
var body = new MemoryStream([123, 34, 110, 97, 109, 101, 34, 58, 34, 116, 101, 115, 116, 34, 125]); // {"name":"test"}
|
||||
|
||||
// Act
|
||||
var context = new RawRequestContext
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/api/users",
|
||||
Headers = headers,
|
||||
Body = body
|
||||
};
|
||||
|
||||
// Assert
|
||||
context.Method.Should().Be("POST");
|
||||
context.Headers["Content-Type"].Should().Be("application/json");
|
||||
context.Body.Length.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Microservice.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="RawResponse"/>.
|
||||
/// </summary>
|
||||
public sealed class RawResponseTests
|
||||
{
|
||||
#region Default Values Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_StatusCode_DefaultsTo200()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = new RawResponse();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Headers_DefaultsToEmpty()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = new RawResponse();
|
||||
|
||||
// Assert
|
||||
response.Headers.Should().BeSameAs(HeaderCollection.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Body_DefaultsToStreamNull()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = new RawResponse();
|
||||
|
||||
// Assert
|
||||
response.Body.Should().BeSameAs(Stream.Null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Ok Factory Method Tests
|
||||
|
||||
[Fact]
|
||||
public void Ok_WithStream_CreatesOkResponse()
|
||||
{
|
||||
// Arrange
|
||||
var stream = new MemoryStream([1, 2, 3, 4, 5]);
|
||||
|
||||
// Act
|
||||
var response = RawResponse.Ok(stream);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
response.Body.Should().BeSameAs(stream);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ok_WithByteArray_CreatesOkResponse()
|
||||
{
|
||||
// Arrange
|
||||
var data = new byte[] { 1, 2, 3, 4, 5 };
|
||||
|
||||
// Act
|
||||
var response = RawResponse.Ok(data);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
response.Body.Should().BeOfType<MemoryStream>();
|
||||
((MemoryStream)response.Body).ToArray().Should().BeEquivalentTo(data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ok_WithString_CreatesOkResponse()
|
||||
{
|
||||
// Arrange
|
||||
var text = "Hello, World!";
|
||||
|
||||
// Act
|
||||
var response = RawResponse.Ok(text);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
using var reader = new StreamReader(response.Body, Encoding.UTF8);
|
||||
reader.ReadToEnd().Should().Be(text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ok_WithEmptyString_CreatesOkResponse()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.Ok("");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(200);
|
||||
response.Body.Length.Should().Be(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NoContent Factory Method Tests
|
||||
|
||||
[Fact]
|
||||
public void NoContent_Creates204Response()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.NoContent();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(204);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoContent_HasDefaultHeaders()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.NoContent();
|
||||
|
||||
// Assert
|
||||
response.Headers.Should().BeSameAs(HeaderCollection.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoContent_HasDefaultBody()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.NoContent();
|
||||
|
||||
// Assert
|
||||
response.Body.Should().BeSameAs(Stream.Null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region BadRequest Factory Method Tests
|
||||
|
||||
[Fact]
|
||||
public void BadRequest_Creates400Response()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.BadRequest();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(400);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BadRequest_WithDefaultMessage_HasBadRequestText()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.BadRequest();
|
||||
|
||||
// Assert
|
||||
using var reader = new StreamReader(response.Body, Encoding.UTF8);
|
||||
reader.ReadToEnd().Should().Be("Bad Request");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BadRequest_WithCustomMessage_HasCustomText()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.BadRequest("Invalid input");
|
||||
|
||||
// Assert
|
||||
using var reader = new StreamReader(response.Body, Encoding.UTF8);
|
||||
reader.ReadToEnd().Should().Be("Invalid input");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BadRequest_SetsTextPlainContentType()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.BadRequest();
|
||||
|
||||
// Assert
|
||||
response.Headers["Content-Type"].Should().Be("text/plain; charset=utf-8");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NotFound Factory Method Tests
|
||||
|
||||
[Fact]
|
||||
public void NotFound_Creates404Response()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.NotFound();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(404);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotFound_WithDefaultMessage_HasNotFoundText()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.NotFound();
|
||||
|
||||
// Assert
|
||||
using var reader = new StreamReader(response.Body, Encoding.UTF8);
|
||||
reader.ReadToEnd().Should().Be("Not Found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotFound_WithCustomMessage_HasCustomText()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.NotFound("Resource does not exist");
|
||||
|
||||
// Assert
|
||||
using var reader = new StreamReader(response.Body, Encoding.UTF8);
|
||||
reader.ReadToEnd().Should().Be("Resource does not exist");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region InternalError Factory Method Tests
|
||||
|
||||
[Fact]
|
||||
public void InternalError_Creates500Response()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.InternalError();
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(500);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InternalError_WithDefaultMessage_HasInternalServerErrorText()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.InternalError();
|
||||
|
||||
// Assert
|
||||
using var reader = new StreamReader(response.Body, Encoding.UTF8);
|
||||
reader.ReadToEnd().Should().Be("Internal Server Error");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InternalError_WithCustomMessage_HasCustomText()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.InternalError("Database connection failed");
|
||||
|
||||
// Assert
|
||||
using var reader = new StreamReader(response.Body, Encoding.UTF8);
|
||||
reader.ReadToEnd().Should().Be("Database connection failed");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Factory Method Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(400, "Bad Request")]
|
||||
[InlineData(401, "Unauthorized")]
|
||||
[InlineData(403, "Forbidden")]
|
||||
[InlineData(404, "Not Found")]
|
||||
[InlineData(500, "Internal Server Error")]
|
||||
[InlineData(502, "Bad Gateway")]
|
||||
[InlineData(503, "Service Unavailable")]
|
||||
public void Error_CreatesResponseWithCorrectStatusCode(int statusCode, string message)
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.Error(statusCode, message);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(statusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Error_SetsCorrectContentType()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = RawResponse.Error(418, "I'm a teapot");
|
||||
|
||||
// Assert
|
||||
response.Headers["Content-Type"].Should().Be("text/plain; charset=utf-8");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Error_SetsMessageInBody()
|
||||
{
|
||||
// Arrange
|
||||
var message = "Custom error message";
|
||||
|
||||
// Act
|
||||
var response = RawResponse.Error(400, message);
|
||||
|
||||
// Assert
|
||||
using var reader = new StreamReader(response.Body, Encoding.UTF8);
|
||||
reader.ReadToEnd().Should().Be(message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Error_WithUnicodeMessage_EncodesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var message = "Error: \u4e2d\u6587\u6d88\u606f";
|
||||
|
||||
// Act
|
||||
var response = RawResponse.Error(400, message);
|
||||
|
||||
// Assert
|
||||
using var reader = new StreamReader(response.Body, Encoding.UTF8);
|
||||
reader.ReadToEnd().Should().Be(message);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Property Initialization Tests
|
||||
|
||||
[Fact]
|
||||
public void StatusCode_CanBeInitialized()
|
||||
{
|
||||
// Arrange & Act
|
||||
var response = new RawResponse { StatusCode = 201 };
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(201);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Headers_CanBeInitialized()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new HeaderCollection();
|
||||
headers.Set("X-Custom", "value");
|
||||
|
||||
// Act
|
||||
var response = new RawResponse { Headers = headers };
|
||||
|
||||
// Assert
|
||||
response.Headers["X-Custom"].Should().Be("value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Body_CanBeInitialized()
|
||||
{
|
||||
// Arrange
|
||||
var stream = new MemoryStream([1, 2, 3]);
|
||||
|
||||
// Act
|
||||
var response = new RawResponse { Body = stream };
|
||||
|
||||
// Assert
|
||||
response.Body.Should().BeSameAs(stream);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<!-- Suppress CA2255 from OpenSSL auto-init shim included via Directory.Build.props -->
|
||||
<NoWarn>$(NoWarn);CA2255</NoWarn>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>StellaOps.Microservice.Tests</RootNamespace>
|
||||
<!-- Disable Concelier test infrastructure (Mongo2Go, etc.) since not needed for Microservice SDK tests -->
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Test SDK packages come from Directory.Build.props -->
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Microservice\StellaOps.Microservice.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Router.Testing\StellaOps.Router.Testing.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,550 @@
|
||||
using System.Text;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Frames;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Common.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="FrameConverter"/>.
|
||||
/// </summary>
|
||||
public sealed class FrameConverterTests
|
||||
{
|
||||
#region ToFrame (RequestFrame) Tests
|
||||
|
||||
[Fact]
|
||||
public void ToFrame_RequestFrame_ReturnsFrameWithRequestType()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequestFrame();
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(request);
|
||||
|
||||
// Assert
|
||||
frame.Type.Should().Be(FrameType.Request);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToFrame_RequestFrame_SetsCorrelationIdFromRequest()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequestFrame(correlationId: "test-correlation-123");
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(request);
|
||||
|
||||
// Assert
|
||||
frame.CorrelationId.Should().Be("test-correlation-123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToFrame_RequestFrame_UsesRequestIdWhenCorrelationIdIsNull()
|
||||
{
|
||||
// Arrange
|
||||
var request = new RequestFrame
|
||||
{
|
||||
RequestId = "request-id-456",
|
||||
CorrelationId = null,
|
||||
Method = "GET",
|
||||
Path = "/test"
|
||||
};
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(request);
|
||||
|
||||
// Assert
|
||||
frame.CorrelationId.Should().Be("request-id-456");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToFrame_RequestFrame_SerializesPayload()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequestFrame();
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(request);
|
||||
|
||||
// Assert
|
||||
frame.Payload.Length.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ToRequestFrame Tests
|
||||
|
||||
[Fact]
|
||||
public void ToRequestFrame_ValidRequestFrame_ReturnsRequestFrame()
|
||||
{
|
||||
// Arrange
|
||||
var originalRequest = CreateTestRequestFrame();
|
||||
var frame = FrameConverter.ToFrame(originalRequest);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToRequestFrame_WrongFrameType_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var frame = new Frame
|
||||
{
|
||||
Type = FrameType.Response,
|
||||
CorrelationId = "test",
|
||||
Payload = Array.Empty<byte>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToRequestFrame_InvalidJson_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var frame = new Frame
|
||||
{
|
||||
Type = FrameType.Request,
|
||||
CorrelationId = "test",
|
||||
Payload = Encoding.UTF8.GetBytes("invalid json {{{")
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToRequestFrame_RoundTrip_PreservesRequestId()
|
||||
{
|
||||
// Arrange
|
||||
var originalRequest = CreateTestRequestFrame(requestId: "unique-request-id");
|
||||
var frame = FrameConverter.ToFrame(originalRequest);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
result!.RequestId.Should().Be("unique-request-id");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToRequestFrame_RoundTrip_PreservesMethod()
|
||||
{
|
||||
// Arrange
|
||||
var originalRequest = CreateTestRequestFrame(method: "DELETE");
|
||||
var frame = FrameConverter.ToFrame(originalRequest);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
result!.Method.Should().Be("DELETE");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToRequestFrame_RoundTrip_PreservesPath()
|
||||
{
|
||||
// Arrange
|
||||
var originalRequest = CreateTestRequestFrame(path: "/api/users/123");
|
||||
var frame = FrameConverter.ToFrame(originalRequest);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
result!.Path.Should().Be("/api/users/123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToRequestFrame_RoundTrip_PreservesHeaders()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new Dictionary<string, string>
|
||||
{
|
||||
["Content-Type"] = "application/json",
|
||||
["X-Custom-Header"] = "custom-value"
|
||||
};
|
||||
var originalRequest = new RequestFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
Method = "POST",
|
||||
Path = "/test",
|
||||
Headers = headers
|
||||
};
|
||||
var frame = FrameConverter.ToFrame(originalRequest);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
result!.Headers.Should().ContainKey("Content-Type");
|
||||
result.Headers["Content-Type"].Should().Be("application/json");
|
||||
result.Headers["X-Custom-Header"].Should().Be("custom-value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToRequestFrame_RoundTrip_PreservesPayload()
|
||||
{
|
||||
// Arrange
|
||||
var payloadBytes = Encoding.UTF8.GetBytes("{\"key\":\"value\"}");
|
||||
var originalRequest = new RequestFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
Method = "POST",
|
||||
Path = "/test",
|
||||
Payload = payloadBytes
|
||||
};
|
||||
var frame = FrameConverter.ToFrame(originalRequest);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
result!.Payload.ToArray().Should().BeEquivalentTo(payloadBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToRequestFrame_RoundTrip_PreservesTimeoutSeconds()
|
||||
{
|
||||
// Arrange
|
||||
var originalRequest = new RequestFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
Method = "GET",
|
||||
Path = "/test",
|
||||
TimeoutSeconds = 60
|
||||
};
|
||||
var frame = FrameConverter.ToFrame(originalRequest);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
result!.TimeoutSeconds.Should().Be(60);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToRequestFrame_RoundTrip_PreservesSupportsStreaming()
|
||||
{
|
||||
// Arrange
|
||||
var originalRequest = new RequestFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
Method = "GET",
|
||||
Path = "/test",
|
||||
SupportsStreaming = true
|
||||
};
|
||||
var frame = FrameConverter.ToFrame(originalRequest);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
result!.SupportsStreaming.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ToFrame (ResponseFrame) Tests
|
||||
|
||||
[Fact]
|
||||
public void ToFrame_ResponseFrame_ReturnsFrameWithResponseType()
|
||||
{
|
||||
// Arrange
|
||||
var response = CreateTestResponseFrame();
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(response);
|
||||
|
||||
// Assert
|
||||
frame.Type.Should().Be(FrameType.Response);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToFrame_ResponseFrame_SetsCorrelationIdToRequestId()
|
||||
{
|
||||
// Arrange
|
||||
var response = CreateTestResponseFrame(requestId: "req-123");
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(response);
|
||||
|
||||
// Assert
|
||||
frame.CorrelationId.Should().Be("req-123");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ToResponseFrame Tests
|
||||
|
||||
[Fact]
|
||||
public void ToResponseFrame_ValidResponseFrame_ReturnsResponseFrame()
|
||||
{
|
||||
// Arrange
|
||||
var originalResponse = CreateTestResponseFrame();
|
||||
var frame = FrameConverter.ToFrame(originalResponse);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToResponseFrame(frame);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToResponseFrame_WrongFrameType_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var frame = new Frame
|
||||
{
|
||||
Type = FrameType.Request,
|
||||
CorrelationId = "test",
|
||||
Payload = Array.Empty<byte>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToResponseFrame(frame);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToResponseFrame_InvalidJson_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var frame = new Frame
|
||||
{
|
||||
Type = FrameType.Response,
|
||||
CorrelationId = "test",
|
||||
Payload = Encoding.UTF8.GetBytes("not valid json")
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToResponseFrame(frame);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToResponseFrame_RoundTrip_PreservesRequestId()
|
||||
{
|
||||
// Arrange
|
||||
var originalResponse = CreateTestResponseFrame(requestId: "original-req-id");
|
||||
var frame = FrameConverter.ToFrame(originalResponse);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToResponseFrame(frame);
|
||||
|
||||
// Assert
|
||||
result!.RequestId.Should().Be("original-req-id");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToResponseFrame_RoundTrip_PreservesStatusCode()
|
||||
{
|
||||
// Arrange
|
||||
var originalResponse = CreateTestResponseFrame(statusCode: 404);
|
||||
var frame = FrameConverter.ToFrame(originalResponse);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToResponseFrame(frame);
|
||||
|
||||
// Assert
|
||||
result!.StatusCode.Should().Be(404);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToResponseFrame_RoundTrip_PreservesHeaders()
|
||||
{
|
||||
// Arrange
|
||||
var headers = new Dictionary<string, string>
|
||||
{
|
||||
["Content-Type"] = "application/json",
|
||||
["Cache-Control"] = "no-cache"
|
||||
};
|
||||
var originalResponse = new ResponseFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
StatusCode = 200,
|
||||
Headers = headers
|
||||
};
|
||||
var frame = FrameConverter.ToFrame(originalResponse);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToResponseFrame(frame);
|
||||
|
||||
// Assert
|
||||
result!.Headers["Content-Type"].Should().Be("application/json");
|
||||
result.Headers["Cache-Control"].Should().Be("no-cache");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToResponseFrame_RoundTrip_PreservesPayload()
|
||||
{
|
||||
// Arrange
|
||||
var payloadBytes = Encoding.UTF8.GetBytes("{\"result\":\"success\"}");
|
||||
var originalResponse = new ResponseFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
StatusCode = 200,
|
||||
Payload = payloadBytes
|
||||
};
|
||||
var frame = FrameConverter.ToFrame(originalResponse);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToResponseFrame(frame);
|
||||
|
||||
// Assert
|
||||
result!.Payload.ToArray().Should().BeEquivalentTo(payloadBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToResponseFrame_RoundTrip_PreservesHasMoreChunks()
|
||||
{
|
||||
// Arrange
|
||||
var originalResponse = new ResponseFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
StatusCode = 200,
|
||||
HasMoreChunks = true
|
||||
};
|
||||
var frame = FrameConverter.ToFrame(originalResponse);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToResponseFrame(frame);
|
||||
|
||||
// Assert
|
||||
result!.HasMoreChunks.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases
|
||||
|
||||
[Fact]
|
||||
public void ToRequestFrame_EmptyPayload_ReturnsEmptyPayload()
|
||||
{
|
||||
// Arrange
|
||||
var originalRequest = new RequestFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
Method = "GET",
|
||||
Path = "/test",
|
||||
Payload = Array.Empty<byte>()
|
||||
};
|
||||
var frame = FrameConverter.ToFrame(originalRequest);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
result!.Payload.IsEmpty.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToRequestFrame_NullHeaders_ReturnsEmptyHeaders()
|
||||
{
|
||||
// Arrange
|
||||
var originalRequest = new RequestFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
Method = "GET",
|
||||
Path = "/test"
|
||||
};
|
||||
var frame = FrameConverter.ToFrame(originalRequest);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
result!.Headers.Should().NotBeNull();
|
||||
result.Headers.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToResponseFrame_EmptyPayload_ReturnsEmptyPayload()
|
||||
{
|
||||
// Arrange
|
||||
var originalResponse = new ResponseFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
StatusCode = 204,
|
||||
Payload = Array.Empty<byte>()
|
||||
};
|
||||
var frame = FrameConverter.ToFrame(originalResponse);
|
||||
|
||||
// Act
|
||||
var result = FrameConverter.ToResponseFrame(frame);
|
||||
|
||||
// Assert
|
||||
result!.Payload.IsEmpty.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToFrame_LargePayload_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var largePayload = new byte[1024 * 1024]; // 1MB
|
||||
Random.Shared.NextBytes(largePayload);
|
||||
var originalRequest = new RequestFrame
|
||||
{
|
||||
RequestId = "test-id",
|
||||
Method = "POST",
|
||||
Path = "/upload",
|
||||
Payload = largePayload
|
||||
};
|
||||
|
||||
// Act
|
||||
var frame = FrameConverter.ToFrame(originalRequest);
|
||||
var result = FrameConverter.ToRequestFrame(frame);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Payload.ToArray().Should().BeEquivalentTo(largePayload);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static RequestFrame CreateTestRequestFrame(
|
||||
string? requestId = null,
|
||||
string? correlationId = null,
|
||||
string method = "GET",
|
||||
string path = "/test")
|
||||
{
|
||||
return new RequestFrame
|
||||
{
|
||||
RequestId = requestId ?? Guid.NewGuid().ToString("N"),
|
||||
CorrelationId = correlationId,
|
||||
Method = method,
|
||||
Path = path
|
||||
};
|
||||
}
|
||||
|
||||
private static ResponseFrame CreateTestResponseFrame(
|
||||
string? requestId = null,
|
||||
int statusCode = 200)
|
||||
{
|
||||
return new ResponseFrame
|
||||
{
|
||||
RequestId = requestId ?? Guid.NewGuid().ToString("N"),
|
||||
StatusCode = statusCode
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
namespace StellaOps.Router.Common.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="PathMatcher"/>.
|
||||
/// </summary>
|
||||
public sealed class PathMatcherTests
|
||||
{
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsTemplate()
|
||||
{
|
||||
// Arrange & Act
|
||||
var matcher = new PathMatcher("/api/users/{id}");
|
||||
|
||||
// Assert
|
||||
matcher.Template.Should().Be("/api/users/{id}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DefaultsCaseInsensitive()
|
||||
{
|
||||
// Arrange & Act
|
||||
var matcher = new PathMatcher("/api/Users");
|
||||
|
||||
// Assert
|
||||
matcher.IsMatch("/api/users").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CaseSensitive_DoesNotMatchDifferentCase()
|
||||
{
|
||||
// Arrange & Act
|
||||
var matcher = new PathMatcher("/api/Users", caseInsensitive: false);
|
||||
|
||||
// Assert
|
||||
matcher.IsMatch("/api/users").Should().BeFalse();
|
||||
matcher.IsMatch("/api/Users").Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsMatch Tests - Exact Paths
|
||||
|
||||
[Fact]
|
||||
public void IsMatch_ExactPath_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/health");
|
||||
|
||||
// Act & Assert
|
||||
matcher.IsMatch("/api/health").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsMatch_ExactPath_TrailingSlash_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/health");
|
||||
|
||||
// Act & Assert
|
||||
matcher.IsMatch("/api/health/").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsMatch_ExactPath_NoLeadingSlash_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/health");
|
||||
|
||||
// Act & Assert
|
||||
matcher.IsMatch("api/health").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsMatch_DifferentPath_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/health");
|
||||
|
||||
// Act & Assert
|
||||
matcher.IsMatch("/api/status").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsMatch_PartialPath_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/list");
|
||||
|
||||
// Act & Assert
|
||||
matcher.IsMatch("/api/users").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsMatch_LongerPath_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users");
|
||||
|
||||
// Act & Assert
|
||||
matcher.IsMatch("/api/users/list").Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsMatch Tests - Case Sensitivity
|
||||
|
||||
[Fact]
|
||||
public void IsMatch_CaseInsensitive_MatchesMixedCase()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users", caseInsensitive: true);
|
||||
|
||||
// Act & Assert
|
||||
matcher.IsMatch("/API/USERS").Should().BeTrue();
|
||||
matcher.IsMatch("/Api/Users").Should().BeTrue();
|
||||
matcher.IsMatch("/aPi/uSeRs").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsMatch_CaseSensitive_OnlyMatchesExactCase()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/Api/Users", caseInsensitive: false);
|
||||
|
||||
// Act & Assert
|
||||
matcher.IsMatch("/Api/Users").Should().BeTrue();
|
||||
matcher.IsMatch("/api/users").Should().BeFalse();
|
||||
matcher.IsMatch("/API/USERS").Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TryMatch Tests - Single Parameter
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_SingleParameter_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{id}");
|
||||
|
||||
// Act
|
||||
var result = matcher.TryMatch("/api/users/123", out var parameters);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_SingleParameter_ExtractsParameter()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{id}");
|
||||
|
||||
// Act
|
||||
matcher.TryMatch("/api/users/123", out var parameters);
|
||||
|
||||
// Assert
|
||||
parameters.Should().ContainKey("id");
|
||||
parameters["id"].Should().Be("123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_SingleParameter_ExtractsGuidParameter()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{userId}");
|
||||
var guid = Guid.NewGuid().ToString();
|
||||
|
||||
// Act
|
||||
matcher.TryMatch($"/api/users/{guid}", out var parameters);
|
||||
|
||||
// Assert
|
||||
parameters["userId"].Should().Be(guid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_SingleParameter_ExtractsStringParameter()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{username}");
|
||||
|
||||
// Act
|
||||
matcher.TryMatch("/api/users/john-doe", out var parameters);
|
||||
|
||||
// Assert
|
||||
parameters["username"].Should().Be("john-doe");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TryMatch Tests - Multiple Parameters
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_MultipleParameters_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{userId}/posts/{postId}");
|
||||
|
||||
// Act
|
||||
var result = matcher.TryMatch("/api/users/123/posts/456", out _);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_MultipleParameters_ExtractsAllParameters()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{userId}/posts/{postId}");
|
||||
|
||||
// Act
|
||||
matcher.TryMatch("/api/users/user-1/posts/post-2", out var parameters);
|
||||
|
||||
// Assert
|
||||
parameters.Should().ContainKey("userId");
|
||||
parameters.Should().ContainKey("postId");
|
||||
parameters["userId"].Should().Be("user-1");
|
||||
parameters["postId"].Should().Be("post-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_ThreeParameters_ExtractsAllParameters()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/org/{orgId}/users/{userId}/roles/{roleId}");
|
||||
|
||||
// Act
|
||||
matcher.TryMatch("/api/org/acme/users/john/roles/admin", out var parameters);
|
||||
|
||||
// Assert
|
||||
parameters.Should().HaveCount(3);
|
||||
parameters["orgId"].Should().Be("acme");
|
||||
parameters["userId"].Should().Be("john");
|
||||
parameters["roleId"].Should().Be("admin");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TryMatch Tests - Non-Matching
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_NonMatchingPath_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{id}");
|
||||
|
||||
// Act
|
||||
var result = matcher.TryMatch("/api/posts/123", out var parameters);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
parameters.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_MissingParameter_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{id}/posts/{postId}");
|
||||
|
||||
// Act
|
||||
var result = matcher.TryMatch("/api/users/123/posts", out var parameters);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_ExtraSegment_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{id}");
|
||||
|
||||
// Act
|
||||
var result = matcher.TryMatch("/api/users/123/extra", out _);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TryMatch Tests - Path Normalization
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_TrailingSlash_Matches()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{id}");
|
||||
|
||||
// Act
|
||||
var result = matcher.TryMatch("/api/users/123/", out var parameters);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
parameters["id"].Should().Be("123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_NoLeadingSlash_Matches()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{id}");
|
||||
|
||||
// Act
|
||||
var result = matcher.TryMatch("api/users/123", out var parameters);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
parameters["id"].Should().Be("123");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TryMatch Tests - Parameter Type Constraints
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_ParameterWithTypeConstraint_ExtractsParameterName()
|
||||
{
|
||||
// Arrange
|
||||
// The PathMatcher ignores type constraints but still extracts the parameter
|
||||
var matcher = new PathMatcher("/api/users/{id:int}");
|
||||
|
||||
// Act
|
||||
matcher.TryMatch("/api/users/123", out var parameters);
|
||||
|
||||
// Assert
|
||||
parameters.Should().ContainKey("id");
|
||||
parameters["id"].Should().Be("123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_ParameterWithGuidConstraint_ExtractsParameterName()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{id:guid}");
|
||||
|
||||
// Act
|
||||
matcher.TryMatch("/api/users/abc-123", out var parameters);
|
||||
|
||||
// Assert
|
||||
parameters.Should().ContainKey("id");
|
||||
parameters["id"].Should().Be("abc-123");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_RootPath_Matches()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/");
|
||||
|
||||
// Act
|
||||
var result = matcher.TryMatch("/", out var parameters);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
parameters.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_SingleSegmentWithParameter_Matches()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/{id}");
|
||||
|
||||
// Act
|
||||
var result = matcher.TryMatch("/test-value", out var parameters);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
parameters["id"].Should().Be("test-value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsMatch_EmptyPath_HandlesGracefully()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/");
|
||||
|
||||
// Act
|
||||
var result = matcher.IsMatch("");
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_ParameterWithHyphen_Extracts()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{user-id}");
|
||||
|
||||
// Act
|
||||
matcher.TryMatch("/api/users/123", out var parameters);
|
||||
|
||||
// Assert
|
||||
parameters.Should().ContainKey("user-id");
|
||||
parameters["user-id"].Should().Be("123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_ParameterWithUnderscore_Extracts()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/users/{user_id}");
|
||||
|
||||
// Act
|
||||
matcher.TryMatch("/api/users/456", out var parameters);
|
||||
|
||||
// Assert
|
||||
parameters.Should().ContainKey("user_id");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_SpecialCharactersInPath_Matches()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/api/search/{query}");
|
||||
|
||||
// Act
|
||||
matcher.TryMatch("/api/search/hello-world_test.123", out var parameters);
|
||||
|
||||
// Assert
|
||||
parameters["query"].Should().Be("hello-world_test.123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsMatch_ComplexRealWorldPath_Matches()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/v1/organizations/{orgId}/projects/{projectId}/scans/{scanId}/vulnerabilities");
|
||||
|
||||
// Act
|
||||
var result = matcher.IsMatch("/v1/organizations/acme-corp/projects/webapp/scans/scan-2024-001/vulnerabilities");
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryMatch_ComplexRealWorldPath_ExtractsAllParameters()
|
||||
{
|
||||
// Arrange
|
||||
var matcher = new PathMatcher("/v1/organizations/{orgId}/projects/{projectId}/scans/{scanId}");
|
||||
|
||||
// Act
|
||||
matcher.TryMatch("/v1/organizations/acme-corp/projects/webapp/scans/scan-2024-001", out var parameters);
|
||||
|
||||
// Assert
|
||||
parameters["orgId"].Should().Be("acme-corp");
|
||||
parameters["projectId"].Should().Be("webapp");
|
||||
parameters["scanId"].Should().Be("scan-2024-001");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<!-- Suppress CA2255 from OpenSSL auto-init shim included via Directory.Build.props -->
|
||||
<NoWarn>$(NoWarn);CA2255</NoWarn>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>StellaOps.Router.Common.Tests</RootNamespace>
|
||||
<!-- Disable Concelier test infrastructure (Mongo2Go, etc.) since not needed for Router tests -->
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Test SDK packages come from Directory.Build.props -->
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Router.Testing\StellaOps.Router.Testing.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,87 @@
|
||||
namespace StellaOps.Router.Config.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ConfigChangedEventArgs"/>.
|
||||
/// </summary>
|
||||
public sealed class ConfigChangedEventArgsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_SetsPreviousConfig()
|
||||
{
|
||||
// Arrange
|
||||
var previous = new RouterConfig();
|
||||
var current = new RouterConfig();
|
||||
|
||||
// Act
|
||||
var args = new ConfigChangedEventArgs(previous, current);
|
||||
|
||||
// Assert
|
||||
args.Previous.Should().BeSameAs(previous);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsCurrentConfig()
|
||||
{
|
||||
// Arrange
|
||||
var previous = new RouterConfig();
|
||||
var current = new RouterConfig();
|
||||
|
||||
// Act
|
||||
var args = new ConfigChangedEventArgs(previous, current);
|
||||
|
||||
// Assert
|
||||
args.Current.Should().BeSameAs(current);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsChangedAtToCurrentTime()
|
||||
{
|
||||
// Arrange
|
||||
var previous = new RouterConfig();
|
||||
var current = new RouterConfig();
|
||||
var beforeCreate = DateTime.UtcNow;
|
||||
|
||||
// Act
|
||||
var args = new ConfigChangedEventArgs(previous, current);
|
||||
var afterCreate = DateTime.UtcNow;
|
||||
|
||||
// Assert
|
||||
args.ChangedAt.Should().BeOnOrAfter(beforeCreate);
|
||||
args.ChangedAt.Should().BeOnOrBefore(afterCreate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DifferentConfigs_BothAccessible()
|
||||
{
|
||||
// Arrange
|
||||
var previous = new RouterConfig
|
||||
{
|
||||
Routing = new RoutingOptions { LocalRegion = "us-west-1" }
|
||||
};
|
||||
var current = new RouterConfig
|
||||
{
|
||||
Routing = new RoutingOptions { LocalRegion = "us-east-1" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var args = new ConfigChangedEventArgs(previous, current);
|
||||
|
||||
// Assert
|
||||
args.Previous.Routing.LocalRegion.Should().Be("us-west-1");
|
||||
args.Current.Routing.LocalRegion.Should().Be("us-east-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigChangedEventArgs_InheritsFromEventArgs()
|
||||
{
|
||||
// Arrange
|
||||
var previous = new RouterConfig();
|
||||
var current = new RouterConfig();
|
||||
|
||||
// Act
|
||||
var args = new ConfigChangedEventArgs(previous, current);
|
||||
|
||||
// Assert
|
||||
args.Should().BeAssignableTo<EventArgs>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
namespace StellaOps.Router.Config.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ConfigValidationResult"/>.
|
||||
/// </summary>
|
||||
public sealed class ConfigValidationResultTests
|
||||
{
|
||||
#region Default Values Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Errors_DefaultsToEmptyList()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = new ConfigValidationResult();
|
||||
|
||||
// Assert
|
||||
result.Errors.Should().NotBeNull();
|
||||
result.Errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Warnings_DefaultsToEmptyList()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = new ConfigValidationResult();
|
||||
|
||||
// Assert
|
||||
result.Warnings.Should().NotBeNull();
|
||||
result.Warnings.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsValid Tests
|
||||
|
||||
[Fact]
|
||||
public void IsValid_NoErrors_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var result = new ConfigValidationResult();
|
||||
|
||||
// Act & Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValid_WithErrors_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var result = new ConfigValidationResult();
|
||||
result.Errors.Add("Some error");
|
||||
|
||||
// Act & Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValid_WithOnlyWarnings_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var result = new ConfigValidationResult();
|
||||
result.Warnings.Add("Some warning");
|
||||
|
||||
// Act & Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValid_WithErrorsAndWarnings_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var result = new ConfigValidationResult();
|
||||
result.Errors.Add("Some error");
|
||||
result.Warnings.Add("Some warning");
|
||||
|
||||
// Act & Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValid_MultipleErrors_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var result = new ConfigValidationResult();
|
||||
result.Errors.Add("Error 1");
|
||||
result.Errors.Add("Error 2");
|
||||
result.Errors.Add("Error 3");
|
||||
|
||||
// Act & Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Static Success Tests
|
||||
|
||||
[Fact]
|
||||
public void Success_ReturnsValidResult()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = ConfigValidationResult.Success;
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Errors.Should().BeEmpty();
|
||||
result.Warnings.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Success_ReturnsNewInstance()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result1 = ConfigValidationResult.Success;
|
||||
var result2 = ConfigValidationResult.Success;
|
||||
|
||||
// Assert - Should be different instances to allow mutation without affecting shared state
|
||||
result1.Should().NotBeSameAs(result2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Errors Collection Tests
|
||||
|
||||
[Fact]
|
||||
public void Errors_CanBeModified()
|
||||
{
|
||||
// Arrange
|
||||
var result = new ConfigValidationResult();
|
||||
|
||||
// Act
|
||||
result.Errors.Add("Error 1");
|
||||
result.Errors.Add("Error 2");
|
||||
|
||||
// Assert
|
||||
result.Errors.Should().HaveCount(2);
|
||||
result.Errors.Should().Contain("Error 1");
|
||||
result.Errors.Should().Contain("Error 2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Errors_CanBeInitialized()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = new ConfigValidationResult
|
||||
{
|
||||
Errors = ["Error 1", "Error 2"]
|
||||
};
|
||||
|
||||
// Assert
|
||||
result.Errors.Should().HaveCount(2);
|
||||
result.IsValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Warnings Collection Tests
|
||||
|
||||
[Fact]
|
||||
public void Warnings_CanBeModified()
|
||||
{
|
||||
// Arrange
|
||||
var result = new ConfigValidationResult();
|
||||
|
||||
// Act
|
||||
result.Warnings.Add("Warning 1");
|
||||
result.Warnings.Add("Warning 2");
|
||||
|
||||
// Assert
|
||||
result.Warnings.Should().HaveCount(2);
|
||||
result.Warnings.Should().Contain("Warning 1");
|
||||
result.Warnings.Should().Contain("Warning 2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Warnings_CanBeInitialized()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = new ConfigValidationResult
|
||||
{
|
||||
Warnings = ["Warning 1", "Warning 2"]
|
||||
};
|
||||
|
||||
// Assert
|
||||
result.Warnings.Should().HaveCount(2);
|
||||
result.IsValid.Should().BeTrue(); // Warnings don't affect validity
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
namespace StellaOps.Router.Config.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="RouterConfigOptions"/>.
|
||||
/// </summary>
|
||||
public sealed class RouterConfigOptionsTests
|
||||
{
|
||||
#region Default Values Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ConfigPath_DefaultsToNull()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RouterConfigOptions();
|
||||
|
||||
// Assert
|
||||
options.ConfigPath.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_EnvironmentVariablePrefix_DefaultsToStellaOpsRouter()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RouterConfigOptions();
|
||||
|
||||
// Assert
|
||||
options.EnvironmentVariablePrefix.Should().Be("STELLAOPS_ROUTER_");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_EnableHotReload_DefaultsToTrue()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RouterConfigOptions();
|
||||
|
||||
// Assert
|
||||
options.EnableHotReload.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DebounceInterval_DefaultsTo500Milliseconds()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RouterConfigOptions();
|
||||
|
||||
// Assert
|
||||
options.DebounceInterval.Should().Be(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowOnValidationError_DefaultsToFalse()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RouterConfigOptions();
|
||||
|
||||
// Assert
|
||||
options.ThrowOnValidationError.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ConfigurationSection_DefaultsToRouter()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RouterConfigOptions();
|
||||
|
||||
// Assert
|
||||
options.ConfigurationSection.Should().Be("Router");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Property Assignment Tests
|
||||
|
||||
[Fact]
|
||||
public void ConfigPath_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RouterConfigOptions();
|
||||
|
||||
// Act
|
||||
options.ConfigPath = "/etc/stellaops/router.yaml";
|
||||
|
||||
// Assert
|
||||
options.ConfigPath.Should().Be("/etc/stellaops/router.yaml");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvironmentVariablePrefix_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RouterConfigOptions();
|
||||
|
||||
// Act
|
||||
options.EnvironmentVariablePrefix = "CUSTOM_PREFIX_";
|
||||
|
||||
// Assert
|
||||
options.EnvironmentVariablePrefix.Should().Be("CUSTOM_PREFIX_");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnableHotReload_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RouterConfigOptions();
|
||||
|
||||
// Act
|
||||
options.EnableHotReload = false;
|
||||
|
||||
// Assert
|
||||
options.EnableHotReload.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DebounceInterval_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RouterConfigOptions();
|
||||
|
||||
// Act
|
||||
options.DebounceInterval = TimeSpan.FromSeconds(2);
|
||||
|
||||
// Assert
|
||||
options.DebounceInterval.Should().Be(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThrowOnValidationError_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RouterConfigOptions();
|
||||
|
||||
// Act
|
||||
options.ThrowOnValidationError = true;
|
||||
|
||||
// Assert
|
||||
options.ThrowOnValidationError.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigurationSection_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RouterConfigOptions();
|
||||
|
||||
// Act
|
||||
options.ConfigurationSection = "CustomSection";
|
||||
|
||||
// Assert
|
||||
options.ConfigurationSection.Should().Be("CustomSection");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Config.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="RouterConfigProvider"/> and configuration validation.
|
||||
/// </summary>
|
||||
public sealed class RouterConfigProviderTests : IDisposable
|
||||
{
|
||||
private readonly ILogger<RouterConfigProvider> _logger;
|
||||
private RouterConfigProvider? _provider;
|
||||
|
||||
public RouterConfigProviderTests()
|
||||
{
|
||||
_logger = NullLogger<RouterConfigProvider>.Instance;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_provider?.Dispose();
|
||||
}
|
||||
|
||||
private RouterConfigProvider CreateProvider(RouterConfigOptions? options = null)
|
||||
{
|
||||
var opts = Options.Create(options ?? new RouterConfigOptions { EnableHotReload = false });
|
||||
_provider = new RouterConfigProvider(opts, _logger);
|
||||
return _provider;
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InitializesCurrentConfig()
|
||||
{
|
||||
// Arrange & Act
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Assert
|
||||
provider.Current.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ExposesOptions()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RouterConfigOptions
|
||||
{
|
||||
ConfigPath = "/test/path.yaml",
|
||||
EnableHotReload = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var provider = CreateProvider(options);
|
||||
|
||||
// Assert
|
||||
provider.Options.Should().NotBeNull();
|
||||
provider.Options.ConfigPath.Should().Be("/test/path.yaml");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithHotReloadDisabled_DoesNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RouterConfigOptions { EnableHotReload = false };
|
||||
|
||||
// Act
|
||||
var action = () => CreateProvider(options);
|
||||
|
||||
// Assert
|
||||
action.Should().NotThrow();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate Tests - PayloadLimits
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidConfig_ReturnsIsValid()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ZeroMaxRequestBytesPerCall_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.PayloadLimits = new PayloadLimits { MaxRequestBytesPerCall = 0 };
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("MaxRequestBytesPerCall"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NegativeMaxRequestBytesPerCall_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.PayloadLimits = new PayloadLimits { MaxRequestBytesPerCall = -1 };
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("MaxRequestBytesPerCall"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ZeroMaxRequestBytesPerConnection_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.PayloadLimits = new PayloadLimits { MaxRequestBytesPerConnection = 0 };
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("MaxRequestBytesPerConnection"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ZeroMaxAggregateInflightBytes_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.PayloadLimits = new PayloadLimits { MaxAggregateInflightBytes = 0 };
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("MaxAggregateInflightBytes"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_MaxCallBytesLargerThanConnectionBytes_ReturnsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.PayloadLimits = new PayloadLimits
|
||||
{
|
||||
MaxRequestBytesPerCall = 100 * 1024 * 1024,
|
||||
MaxRequestBytesPerConnection = 10 * 1024 * 1024,
|
||||
MaxAggregateInflightBytes = 1024 * 1024 * 1024
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue(); // Warnings don't fail validation
|
||||
result.Warnings.Should().Contain(w => w.Contains("MaxRequestBytesPerCall") && w.Contains("MaxRequestBytesPerConnection"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate Tests - RoutingOptions
|
||||
|
||||
[Fact]
|
||||
public void Validate_ZeroDefaultTimeout_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.Routing.DefaultTimeout = TimeSpan.Zero;
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("DefaultTimeout"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NegativeDefaultTimeout_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.Routing.DefaultTimeout = TimeSpan.FromSeconds(-1);
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("DefaultTimeout"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate Tests - Services
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptyServiceName_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.Services.Add(new ServiceConfig { ServiceName = "" });
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("Service name cannot be empty"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WhitespaceServiceName_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.Services.Add(new ServiceConfig { ServiceName = " " });
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("Service name cannot be empty"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DuplicateServiceNames_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.Services.Add(new ServiceConfig { ServiceName = "my-service" });
|
||||
provider.Current.Services.Add(new ServiceConfig { ServiceName = "my-service" });
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("Duplicate service name"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DuplicateServiceNamesCaseInsensitive_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.Services.Add(new ServiceConfig { ServiceName = "MyService" });
|
||||
provider.Current.Services.Add(new ServiceConfig { ServiceName = "myservice" });
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("Duplicate service name"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EndpointEmptyMethod_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.Services.Add(new ServiceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Endpoints = [new EndpointConfig { Method = "", Path = "/test" }]
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("endpoint method cannot be empty"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EndpointEmptyPath_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.Services.Add(new ServiceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Endpoints = [new EndpointConfig { Method = "GET", Path = "" }]
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("endpoint path cannot be empty"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EndpointNonPositiveTimeout_ReturnsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.Services.Add(new ServiceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Endpoints = [new EndpointConfig { Method = "GET", Path = "/test", DefaultTimeout = TimeSpan.Zero }]
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue(); // Warnings don't fail validation
|
||||
result.Warnings.Should().Contain(w => w.Contains("non-positive timeout"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate Tests - StaticInstances
|
||||
|
||||
[Fact]
|
||||
public void Validate_StaticInstanceEmptyServiceName_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.StaticInstances.Add(new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "",
|
||||
Version = "1.0",
|
||||
Host = "localhost",
|
||||
Port = 8080
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("Static instance service name cannot be empty"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_StaticInstanceEmptyHost_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.StaticInstances.Add(new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Host = "",
|
||||
Port = 8080
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("host cannot be empty"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(65536)]
|
||||
[InlineData(70000)]
|
||||
public void Validate_StaticInstanceInvalidPort_ReturnsError(int port)
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.StaticInstances.Add(new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Host = "localhost",
|
||||
Port = port
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("port must be between 1 and 65535"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(80)]
|
||||
[InlineData(443)]
|
||||
[InlineData(8080)]
|
||||
[InlineData(65535)]
|
||||
public void Validate_StaticInstanceValidPort_Succeeds(int port)
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.StaticInstances.Add(new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Host = "localhost",
|
||||
Port = port
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(-100)]
|
||||
public void Validate_StaticInstanceNonPositiveWeight_ReturnsWarning(int weight)
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.StaticInstances.Add(new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Host = "localhost",
|
||||
Port = 8080,
|
||||
Weight = weight
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = provider.Validate();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue(); // Warnings don't fail validation
|
||||
result.Warnings.Should().Contain(w => w.Contains("weight should be positive"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ReloadAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ReloadAsync_ValidConfig_UpdatesCurrentConfig()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
await provider.ReloadAsync();
|
||||
|
||||
// Assert - Config should be reloaded (same content in this case since no file)
|
||||
provider.Current.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReloadAsync_InvalidConfig_ThrowsConfigurationException()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
provider.Current.PayloadLimits = new PayloadLimits { MaxRequestBytesPerCall = 0 };
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ConfigurationException>(() => provider.ReloadAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReloadAsync_Cancelled_ThrowsOperationCanceledException()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(() => provider.ReloadAsync(cts.Token));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ConfigurationChanged Event Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ReloadAsync_RaisesConfigurationChangedEvent()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
ConfigChangedEventArgs? eventArgs = null;
|
||||
provider.ConfigurationChanged += (_, args) => eventArgs = args;
|
||||
|
||||
// Act
|
||||
await provider.ReloadAsync();
|
||||
|
||||
// Assert
|
||||
eventArgs.Should().NotBeNull();
|
||||
eventArgs!.Previous.Should().NotBeNull();
|
||||
eventArgs.Current.Should().NotBeNull();
|
||||
eventArgs.ChangedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Dispose Tests
|
||||
|
||||
[Fact]
|
||||
public void Dispose_CanBeCalledMultipleTimes()
|
||||
{
|
||||
// Arrange
|
||||
var provider = CreateProvider();
|
||||
|
||||
// Act
|
||||
var action = () =>
|
||||
{
|
||||
provider.Dispose();
|
||||
provider.Dispose();
|
||||
provider.Dispose();
|
||||
};
|
||||
|
||||
// Assert
|
||||
action.Should().NotThrow();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Config.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="RouterConfig"/>.
|
||||
/// </summary>
|
||||
public sealed class RouterConfigTests
|
||||
{
|
||||
#region Default Values Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_PayloadLimits_DefaultsToNewInstance()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new RouterConfig();
|
||||
|
||||
// Assert
|
||||
config.PayloadLimits.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Routing_DefaultsToNewInstance()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new RouterConfig();
|
||||
|
||||
// Assert
|
||||
config.Routing.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Services_DefaultsToEmptyList()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new RouterConfig();
|
||||
|
||||
// Assert
|
||||
config.Services.Should().NotBeNull();
|
||||
config.Services.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_StaticInstances_DefaultsToEmptyList()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new RouterConfig();
|
||||
|
||||
// Assert
|
||||
config.StaticInstances.Should().NotBeNull();
|
||||
config.StaticInstances.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PayloadLimits Tests
|
||||
|
||||
[Fact]
|
||||
public void PayloadLimits_HasDefaultValues()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new RouterConfig();
|
||||
|
||||
// Assert
|
||||
config.PayloadLimits.MaxRequestBytesPerCall.Should().Be(10 * 1024 * 1024); // 10 MB
|
||||
config.PayloadLimits.MaxRequestBytesPerConnection.Should().Be(100 * 1024 * 1024); // 100 MB
|
||||
config.PayloadLimits.MaxAggregateInflightBytes.Should().Be(1024 * 1024 * 1024); // 1 GB
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PayloadLimits_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var config = new RouterConfig();
|
||||
|
||||
// Act
|
||||
config.PayloadLimits = new PayloadLimits
|
||||
{
|
||||
MaxRequestBytesPerCall = 5 * 1024 * 1024,
|
||||
MaxRequestBytesPerConnection = 50 * 1024 * 1024,
|
||||
MaxAggregateInflightBytes = 500 * 1024 * 1024
|
||||
};
|
||||
|
||||
// Assert
|
||||
config.PayloadLimits.MaxRequestBytesPerCall.Should().Be(5 * 1024 * 1024);
|
||||
config.PayloadLimits.MaxRequestBytesPerConnection.Should().Be(50 * 1024 * 1024);
|
||||
config.PayloadLimits.MaxAggregateInflightBytes.Should().Be(500 * 1024 * 1024);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Routing Tests
|
||||
|
||||
[Fact]
|
||||
public void Routing_HasDefaultValues()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new RouterConfig();
|
||||
|
||||
// Assert
|
||||
config.Routing.LocalRegion.Should().Be("default");
|
||||
config.Routing.TieBreaker.Should().Be(TieBreakerStrategy.RoundRobin);
|
||||
config.Routing.PreferLocalRegion.Should().BeTrue();
|
||||
config.Routing.DefaultTimeout.Should().Be(TimeSpan.FromSeconds(30));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Routing_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var config = new RouterConfig();
|
||||
|
||||
// Act
|
||||
config.Routing = new RoutingOptions
|
||||
{
|
||||
LocalRegion = "us-east-1",
|
||||
TieBreaker = TieBreakerStrategy.LeastLoaded,
|
||||
PreferLocalRegion = false,
|
||||
DefaultTimeout = TimeSpan.FromMinutes(2),
|
||||
NeighborRegions = ["us-west-1", "eu-west-1"]
|
||||
};
|
||||
|
||||
// Assert
|
||||
config.Routing.LocalRegion.Should().Be("us-east-1");
|
||||
config.Routing.TieBreaker.Should().Be(TieBreakerStrategy.LeastLoaded);
|
||||
config.Routing.PreferLocalRegion.Should().BeFalse();
|
||||
config.Routing.DefaultTimeout.Should().Be(TimeSpan.FromMinutes(2));
|
||||
config.Routing.NeighborRegions.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Services Tests
|
||||
|
||||
[Fact]
|
||||
public void Services_CanAddServices()
|
||||
{
|
||||
// Arrange
|
||||
var config = new RouterConfig();
|
||||
|
||||
// Act
|
||||
config.Services.Add(new ServiceConfig { ServiceName = "service-a" });
|
||||
config.Services.Add(new ServiceConfig { ServiceName = "service-b" });
|
||||
|
||||
// Assert
|
||||
config.Services.Should().HaveCount(2);
|
||||
config.Services[0].ServiceName.Should().Be("service-a");
|
||||
config.Services[1].ServiceName.Should().Be("service-b");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Services_CanBeInitialized()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new RouterConfig
|
||||
{
|
||||
Services =
|
||||
[
|
||||
new ServiceConfig { ServiceName = "auth" },
|
||||
new ServiceConfig { ServiceName = "users" },
|
||||
new ServiceConfig { ServiceName = "orders" }
|
||||
]
|
||||
};
|
||||
|
||||
// Assert
|
||||
config.Services.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region StaticInstances Tests
|
||||
|
||||
[Fact]
|
||||
public void StaticInstances_CanAddInstances()
|
||||
{
|
||||
// Arrange
|
||||
var config = new RouterConfig();
|
||||
|
||||
// Act
|
||||
config.StaticInstances.Add(new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "legacy-service",
|
||||
Version = "1.0",
|
||||
Host = "legacy.internal",
|
||||
Port = 9000
|
||||
});
|
||||
|
||||
// Assert
|
||||
config.StaticInstances.Should().HaveCount(1);
|
||||
config.StaticInstances[0].ServiceName.Should().Be("legacy-service");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaticInstances_CanBeInitialized()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new RouterConfig
|
||||
{
|
||||
StaticInstances =
|
||||
[
|
||||
new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "db-proxy",
|
||||
Version = "2.0",
|
||||
Host = "db-proxy-1.internal",
|
||||
Port = 5432
|
||||
},
|
||||
new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "db-proxy",
|
||||
Version = "2.0",
|
||||
Host = "db-proxy-2.internal",
|
||||
Port = 5432
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Assert
|
||||
config.StaticInstances.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Complete Configuration Tests
|
||||
|
||||
[Fact]
|
||||
public void CompleteConfiguration_Works()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new RouterConfig
|
||||
{
|
||||
PayloadLimits = new PayloadLimits
|
||||
{
|
||||
MaxRequestBytesPerCall = 1024 * 1024,
|
||||
MaxRequestBytesPerConnection = 10 * 1024 * 1024,
|
||||
MaxAggregateInflightBytes = 100 * 1024 * 1024
|
||||
},
|
||||
Routing = new RoutingOptions
|
||||
{
|
||||
LocalRegion = "us-east-1",
|
||||
NeighborRegions = ["us-west-1"],
|
||||
TieBreaker = TieBreakerStrategy.ConsistentHash,
|
||||
PreferLocalRegion = true,
|
||||
DefaultTimeout = TimeSpan.FromSeconds(60)
|
||||
},
|
||||
Services =
|
||||
[
|
||||
new ServiceConfig
|
||||
{
|
||||
ServiceName = "api-gateway",
|
||||
DefaultVersion = "1.0.0",
|
||||
Endpoints =
|
||||
[
|
||||
new EndpointConfig { Method = "GET", Path = "/health" }
|
||||
]
|
||||
}
|
||||
],
|
||||
StaticInstances =
|
||||
[
|
||||
new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "api-gateway",
|
||||
Version = "1.0.0",
|
||||
Host = "api-1.internal",
|
||||
Port = 8080,
|
||||
Weight = 100
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Assert
|
||||
config.PayloadLimits.MaxRequestBytesPerCall.Should().Be(1024 * 1024);
|
||||
config.Routing.LocalRegion.Should().Be("us-east-1");
|
||||
config.Services.Should().HaveCount(1);
|
||||
config.StaticInstances.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
namespace StellaOps.Router.Config.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="RoutingOptions"/> and <see cref="TieBreakerStrategy"/>.
|
||||
/// </summary>
|
||||
public sealed class RoutingOptionsTests
|
||||
{
|
||||
#region Default Values Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_LocalRegion_DefaultsToDefault()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RoutingOptions();
|
||||
|
||||
// Assert
|
||||
options.LocalRegion.Should().Be("default");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NeighborRegions_DefaultsToEmptyList()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RoutingOptions();
|
||||
|
||||
// Assert
|
||||
options.NeighborRegions.Should().NotBeNull();
|
||||
options.NeighborRegions.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_TieBreaker_DefaultsToRoundRobin()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RoutingOptions();
|
||||
|
||||
// Assert
|
||||
options.TieBreaker.Should().Be(TieBreakerStrategy.RoundRobin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_PreferLocalRegion_DefaultsToTrue()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RoutingOptions();
|
||||
|
||||
// Assert
|
||||
options.PreferLocalRegion.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DefaultTimeout_DefaultsTo30Seconds()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RoutingOptions();
|
||||
|
||||
// Assert
|
||||
options.DefaultTimeout.Should().Be(TimeSpan.FromSeconds(30));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Property Assignment Tests
|
||||
|
||||
[Fact]
|
||||
public void LocalRegion_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RoutingOptions();
|
||||
|
||||
// Act
|
||||
options.LocalRegion = "us-east-1";
|
||||
|
||||
// Assert
|
||||
options.LocalRegion.Should().Be("us-east-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeighborRegions_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RoutingOptions();
|
||||
|
||||
// Act
|
||||
options.NeighborRegions = ["us-west-1", "eu-west-1"];
|
||||
|
||||
// Assert
|
||||
options.NeighborRegions.Should().HaveCount(2);
|
||||
options.NeighborRegions.Should().Contain("us-west-1");
|
||||
options.NeighborRegions.Should().Contain("eu-west-1");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TieBreakerStrategy.RoundRobin)]
|
||||
[InlineData(TieBreakerStrategy.Random)]
|
||||
[InlineData(TieBreakerStrategy.LeastLoaded)]
|
||||
[InlineData(TieBreakerStrategy.ConsistentHash)]
|
||||
public void TieBreaker_CanBeSetToAllStrategies(TieBreakerStrategy strategy)
|
||||
{
|
||||
// Arrange
|
||||
var options = new RoutingOptions();
|
||||
|
||||
// Act
|
||||
options.TieBreaker = strategy;
|
||||
|
||||
// Assert
|
||||
options.TieBreaker.Should().Be(strategy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreferLocalRegion_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RoutingOptions();
|
||||
|
||||
// Act
|
||||
options.PreferLocalRegion = false;
|
||||
|
||||
// Assert
|
||||
options.PreferLocalRegion.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultTimeout_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RoutingOptions();
|
||||
|
||||
// Act
|
||||
options.DefaultTimeout = TimeSpan.FromMinutes(5);
|
||||
|
||||
// Assert
|
||||
options.DefaultTimeout.Should().Be(TimeSpan.FromMinutes(5));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TieBreakerStrategy Enum Tests
|
||||
|
||||
[Fact]
|
||||
public void TieBreakerStrategy_HasFourValues()
|
||||
{
|
||||
// Arrange & Act
|
||||
var values = Enum.GetValues<TieBreakerStrategy>();
|
||||
|
||||
// Assert
|
||||
values.Should().HaveCount(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TieBreakerStrategy_RoundRobin_HasValueZero()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
((int)TieBreakerStrategy.RoundRobin).Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TieBreakerStrategy_Random_HasValueOne()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
((int)TieBreakerStrategy.Random).Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TieBreakerStrategy_LeastLoaded_HasValueTwo()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
((int)TieBreakerStrategy.LeastLoaded).Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TieBreakerStrategy_ConsistentHash_HasValueThree()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
((int)TieBreakerStrategy.ConsistentHash).Should().Be(3);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Config.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ServiceConfig"/> and <see cref="EndpointConfig"/>.
|
||||
/// </summary>
|
||||
public sealed class ServiceConfigTests
|
||||
{
|
||||
#region ServiceConfig Default Values Tests
|
||||
|
||||
[Fact]
|
||||
public void ServiceConfig_DefaultVersion_DefaultsToNull()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new ServiceConfig { ServiceName = "test" };
|
||||
|
||||
// Assert
|
||||
config.DefaultVersion.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServiceConfig_DefaultTransport_DefaultsToTcp()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new ServiceConfig { ServiceName = "test" };
|
||||
|
||||
// Assert
|
||||
config.DefaultTransport.Should().Be(TransportType.Tcp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServiceConfig_Endpoints_DefaultsToEmptyList()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new ServiceConfig { ServiceName = "test" };
|
||||
|
||||
// Assert
|
||||
config.Endpoints.Should().NotBeNull();
|
||||
config.Endpoints.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ServiceConfig Property Assignment Tests
|
||||
|
||||
[Fact]
|
||||
public void ServiceConfig_ServiceName_CanBeSet()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new ServiceConfig { ServiceName = "my-service" };
|
||||
|
||||
// Assert
|
||||
config.ServiceName.Should().Be("my-service");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServiceConfig_DefaultVersion_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var config = new ServiceConfig { ServiceName = "test" };
|
||||
|
||||
// Act
|
||||
config.DefaultVersion = "1.0.0";
|
||||
|
||||
// Assert
|
||||
config.DefaultVersion.Should().Be("1.0.0");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TransportType.Tcp)]
|
||||
[InlineData(TransportType.Certificate)]
|
||||
[InlineData(TransportType.Udp)]
|
||||
[InlineData(TransportType.InMemory)]
|
||||
[InlineData(TransportType.RabbitMq)]
|
||||
public void ServiceConfig_DefaultTransport_CanBeSetToAllTypes(TransportType transport)
|
||||
{
|
||||
// Arrange
|
||||
var config = new ServiceConfig { ServiceName = "test" };
|
||||
|
||||
// Act
|
||||
config.DefaultTransport = transport;
|
||||
|
||||
// Assert
|
||||
config.DefaultTransport.Should().Be(transport);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServiceConfig_Endpoints_CanAddEndpoints()
|
||||
{
|
||||
// Arrange
|
||||
var config = new ServiceConfig { ServiceName = "test" };
|
||||
|
||||
// Act
|
||||
config.Endpoints.Add(new EndpointConfig { Method = "GET", Path = "/api/health" });
|
||||
config.Endpoints.Add(new EndpointConfig { Method = "POST", Path = "/api/data" });
|
||||
|
||||
// Assert
|
||||
config.Endpoints.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EndpointConfig Default Values Tests
|
||||
|
||||
[Fact]
|
||||
public void EndpointConfig_DefaultTimeout_DefaultsToNull()
|
||||
{
|
||||
// Arrange & Act
|
||||
var endpoint = new EndpointConfig { Method = "GET", Path = "/" };
|
||||
|
||||
// Assert
|
||||
endpoint.DefaultTimeout.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EndpointConfig_SupportsStreaming_DefaultsToFalse()
|
||||
{
|
||||
// Arrange & Act
|
||||
var endpoint = new EndpointConfig { Method = "GET", Path = "/" };
|
||||
|
||||
// Assert
|
||||
endpoint.SupportsStreaming.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EndpointConfig_RequiringClaims_DefaultsToEmptyList()
|
||||
{
|
||||
// Arrange & Act
|
||||
var endpoint = new EndpointConfig { Method = "GET", Path = "/" };
|
||||
|
||||
// Assert
|
||||
endpoint.RequiringClaims.Should().NotBeNull();
|
||||
endpoint.RequiringClaims.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EndpointConfig Property Assignment Tests
|
||||
|
||||
[Fact]
|
||||
public void EndpointConfig_Method_CanBeSet()
|
||||
{
|
||||
// Arrange & Act
|
||||
var endpoint = new EndpointConfig { Method = "DELETE", Path = "/" };
|
||||
|
||||
// Assert
|
||||
endpoint.Method.Should().Be("DELETE");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EndpointConfig_Path_CanBeSet()
|
||||
{
|
||||
// Arrange & Act
|
||||
var endpoint = new EndpointConfig { Method = "GET", Path = "/api/users/{id}" };
|
||||
|
||||
// Assert
|
||||
endpoint.Path.Should().Be("/api/users/{id}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EndpointConfig_DefaultTimeout_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var endpoint = new EndpointConfig { Method = "GET", Path = "/" };
|
||||
|
||||
// Act
|
||||
endpoint.DefaultTimeout = TimeSpan.FromSeconds(60);
|
||||
|
||||
// Assert
|
||||
endpoint.DefaultTimeout.Should().Be(TimeSpan.FromSeconds(60));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EndpointConfig_SupportsStreaming_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var endpoint = new EndpointConfig { Method = "GET", Path = "/" };
|
||||
|
||||
// Act
|
||||
endpoint.SupportsStreaming = true;
|
||||
|
||||
// Assert
|
||||
endpoint.SupportsStreaming.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EndpointConfig_RequiringClaims_CanAddClaims()
|
||||
{
|
||||
// Arrange
|
||||
var endpoint = new EndpointConfig { Method = "GET", Path = "/" };
|
||||
|
||||
// Act
|
||||
endpoint.RequiringClaims.Add(new ClaimRequirement { Type = "role", Value = "admin" });
|
||||
endpoint.RequiringClaims.Add(new ClaimRequirement { Type = "permission", Value = "read" });
|
||||
|
||||
// Assert
|
||||
endpoint.RequiringClaims.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Complex Configuration Tests
|
||||
|
||||
[Fact]
|
||||
public void ServiceConfig_CompleteConfiguration_Works()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new ServiceConfig
|
||||
{
|
||||
ServiceName = "user-service",
|
||||
DefaultVersion = "2.0.0",
|
||||
DefaultTransport = TransportType.Certificate,
|
||||
Endpoints =
|
||||
[
|
||||
new EndpointConfig
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/api/users/{id}",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(10),
|
||||
SupportsStreaming = false,
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "user" }]
|
||||
},
|
||||
new EndpointConfig
|
||||
{
|
||||
Method = "POST",
|
||||
Path = "/api/users",
|
||||
DefaultTimeout = TimeSpan.FromSeconds(30),
|
||||
SupportsStreaming = false,
|
||||
RequiringClaims = [new ClaimRequirement { Type = "role", Value = "admin" }]
|
||||
},
|
||||
new EndpointConfig
|
||||
{
|
||||
Method = "GET",
|
||||
Path = "/api/users/stream",
|
||||
DefaultTimeout = TimeSpan.FromMinutes(5),
|
||||
SupportsStreaming = true
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Assert
|
||||
config.ServiceName.Should().Be("user-service");
|
||||
config.DefaultVersion.Should().Be("2.0.0");
|
||||
config.DefaultTransport.Should().Be(TransportType.Certificate);
|
||||
config.Endpoints.Should().HaveCount(3);
|
||||
config.Endpoints[0].RequiringClaims.Should().HaveCount(1);
|
||||
config.Endpoints[2].SupportsStreaming.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
using StellaOps.Router.Common.Enums;
|
||||
|
||||
namespace StellaOps.Router.Config.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="StaticInstanceConfig"/>.
|
||||
/// </summary>
|
||||
public sealed class StaticInstanceConfigTests
|
||||
{
|
||||
#region Default Values Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Region_DefaultsToDefault()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Host = "localhost",
|
||||
Port = 8080
|
||||
};
|
||||
|
||||
// Assert
|
||||
config.Region.Should().Be("default");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Transport_DefaultsToTcp()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Host = "localhost",
|
||||
Port = 8080
|
||||
};
|
||||
|
||||
// Assert
|
||||
config.Transport.Should().Be(TransportType.Tcp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Weight_DefaultsTo100()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Host = "localhost",
|
||||
Port = 8080
|
||||
};
|
||||
|
||||
// Assert
|
||||
config.Weight.Should().Be(100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Metadata_DefaultsToEmptyDictionary()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Host = "localhost",
|
||||
Port = 8080
|
||||
};
|
||||
|
||||
// Assert
|
||||
config.Metadata.Should().NotBeNull();
|
||||
config.Metadata.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Required Properties Tests
|
||||
|
||||
[Fact]
|
||||
public void ServiceName_IsRequired()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "required-service",
|
||||
Version = "1.0",
|
||||
Host = "localhost",
|
||||
Port = 8080
|
||||
};
|
||||
|
||||
// Assert
|
||||
config.ServiceName.Should().Be("required-service");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Version_IsRequired()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "2.3.4",
|
||||
Host = "localhost",
|
||||
Port = 8080
|
||||
};
|
||||
|
||||
// Assert
|
||||
config.Version.Should().Be("2.3.4");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Host_IsRequired()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Host = "192.168.1.100",
|
||||
Port = 8080
|
||||
};
|
||||
|
||||
// Assert
|
||||
config.Host.Should().Be("192.168.1.100");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Port_IsRequired()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Host = "localhost",
|
||||
Port = 443
|
||||
};
|
||||
|
||||
// Assert
|
||||
config.Port.Should().Be(443);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Property Assignment Tests
|
||||
|
||||
[Fact]
|
||||
public void Region_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var config = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Host = "localhost",
|
||||
Port = 8080
|
||||
};
|
||||
|
||||
// Act
|
||||
config.Region = "us-west-2";
|
||||
|
||||
// Assert
|
||||
config.Region.Should().Be("us-west-2");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TransportType.Tcp)]
|
||||
[InlineData(TransportType.Certificate)]
|
||||
[InlineData(TransportType.Udp)]
|
||||
[InlineData(TransportType.InMemory)]
|
||||
[InlineData(TransportType.RabbitMq)]
|
||||
public void Transport_CanBeSetToAllTypes(TransportType transport)
|
||||
{
|
||||
// Arrange
|
||||
var config = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Host = "localhost",
|
||||
Port = 8080
|
||||
};
|
||||
|
||||
// Act
|
||||
config.Transport = transport;
|
||||
|
||||
// Assert
|
||||
config.Transport.Should().Be(transport);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(50)]
|
||||
[InlineData(100)]
|
||||
[InlineData(200)]
|
||||
[InlineData(1000)]
|
||||
public void Weight_CanBeSet(int weight)
|
||||
{
|
||||
// Arrange
|
||||
var config = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Host = "localhost",
|
||||
Port = 8080
|
||||
};
|
||||
|
||||
// Act
|
||||
config.Weight = weight;
|
||||
|
||||
// Assert
|
||||
config.Weight.Should().Be(weight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Metadata_CanAddEntries()
|
||||
{
|
||||
// Arrange
|
||||
var config = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "test",
|
||||
Version = "1.0",
|
||||
Host = "localhost",
|
||||
Port = 8080
|
||||
};
|
||||
|
||||
// Act
|
||||
config.Metadata["environment"] = "production";
|
||||
config.Metadata["cluster"] = "primary";
|
||||
|
||||
// Assert
|
||||
config.Metadata.Should().HaveCount(2);
|
||||
config.Metadata["environment"].Should().Be("production");
|
||||
config.Metadata["cluster"].Should().Be("primary");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Complex Configuration Tests
|
||||
|
||||
[Fact]
|
||||
public void CompleteConfiguration_Works()
|
||||
{
|
||||
// Arrange & Act
|
||||
var config = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "user-service",
|
||||
Version = "3.2.1",
|
||||
Region = "eu-central-1",
|
||||
Host = "user-svc.internal.example.com",
|
||||
Port = 8443,
|
||||
Transport = TransportType.Certificate,
|
||||
Weight = 150,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["datacenter"] = "dc1",
|
||||
["rack"] = "rack-42",
|
||||
["shard"] = "primary"
|
||||
}
|
||||
};
|
||||
|
||||
// Assert
|
||||
config.ServiceName.Should().Be("user-service");
|
||||
config.Version.Should().Be("3.2.1");
|
||||
config.Region.Should().Be("eu-central-1");
|
||||
config.Host.Should().Be("user-svc.internal.example.com");
|
||||
config.Port.Should().Be(8443);
|
||||
config.Transport.Should().Be(TransportType.Certificate);
|
||||
config.Weight.Should().Be(150);
|
||||
config.Metadata.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleInstances_CanHaveDifferentWeights()
|
||||
{
|
||||
// Arrange & Act
|
||||
var primary = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "api",
|
||||
Version = "1.0",
|
||||
Host = "primary.example.com",
|
||||
Port = 8080,
|
||||
Weight = 200
|
||||
};
|
||||
|
||||
var secondary = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "api",
|
||||
Version = "1.0",
|
||||
Host = "secondary.example.com",
|
||||
Port = 8080,
|
||||
Weight = 100
|
||||
};
|
||||
|
||||
var tertiary = new StaticInstanceConfig
|
||||
{
|
||||
ServiceName = "api",
|
||||
Version = "1.0",
|
||||
Host = "tertiary.example.com",
|
||||
Port = 8080,
|
||||
Weight = 50
|
||||
};
|
||||
|
||||
// Assert
|
||||
primary.Weight.Should().BeGreaterThan(secondary.Weight);
|
||||
secondary.Weight.Should().BeGreaterThan(tertiary.Weight);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<!-- Suppress CA2255 from OpenSSL auto-init shim included via Directory.Build.props -->
|
||||
<NoWarn>$(NoWarn);CA2255</NoWarn>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>StellaOps.Router.Config.Tests</RootNamespace>
|
||||
<!-- Disable Concelier test infrastructure (Mongo2Go, etc.) since not needed for Router tests -->
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Test SDK packages come from Directory.Build.props -->
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Router.Config\StellaOps.Router.Config.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Router.Testing\StellaOps.Router.Testing.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,210 @@
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Frames;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Testing.Factories;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating test frames with sensible defaults.
|
||||
/// </summary>
|
||||
public static class TestFrameFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a request frame with the specified payload.
|
||||
/// </summary>
|
||||
public static Frame CreateRequestFrame(
|
||||
byte[]? payload = null,
|
||||
string? correlationId = null,
|
||||
FrameType frameType = FrameType.Request)
|
||||
{
|
||||
return new Frame
|
||||
{
|
||||
Type = frameType,
|
||||
CorrelationId = correlationId ?? Guid.NewGuid().ToString("N"),
|
||||
Payload = payload ?? Array.Empty<byte>()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a response frame for the given correlation ID.
|
||||
/// </summary>
|
||||
public static Frame CreateResponseFrame(
|
||||
string correlationId,
|
||||
byte[]? payload = null)
|
||||
{
|
||||
return new Frame
|
||||
{
|
||||
Type = FrameType.Response,
|
||||
CorrelationId = correlationId,
|
||||
Payload = payload ?? Array.Empty<byte>()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a hello frame for service registration.
|
||||
/// </summary>
|
||||
public static Frame CreateHelloFrame(
|
||||
string serviceName = "test-service",
|
||||
string version = "1.0.0",
|
||||
string region = "test",
|
||||
string instanceId = "test-instance",
|
||||
IReadOnlyList<EndpointDescriptor>? endpoints = null)
|
||||
{
|
||||
var helloPayload = new HelloPayload
|
||||
{
|
||||
Instance = new InstanceDescriptor
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
ServiceName = serviceName,
|
||||
Version = version,
|
||||
Region = region
|
||||
},
|
||||
Endpoints = endpoints ?? []
|
||||
};
|
||||
|
||||
return new Frame
|
||||
{
|
||||
Type = FrameType.Hello,
|
||||
CorrelationId = Guid.NewGuid().ToString("N"),
|
||||
Payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(helloPayload)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a heartbeat frame.
|
||||
/// </summary>
|
||||
public static Frame CreateHeartbeatFrame(
|
||||
string instanceId = "test-instance",
|
||||
InstanceHealthStatus status = InstanceHealthStatus.Healthy,
|
||||
int inFlightRequestCount = 0,
|
||||
double errorRate = 0.0)
|
||||
{
|
||||
var heartbeatPayload = new HeartbeatPayload
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
Status = status,
|
||||
InFlightRequestCount = inFlightRequestCount,
|
||||
ErrorRate = errorRate,
|
||||
TimestampUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
return new Frame
|
||||
{
|
||||
Type = FrameType.Heartbeat,
|
||||
CorrelationId = Guid.NewGuid().ToString("N"),
|
||||
Payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(heartbeatPayload)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a cancel frame for the given correlation ID.
|
||||
/// </summary>
|
||||
public static Frame CreateCancelFrame(
|
||||
string correlationId,
|
||||
string? reason = null)
|
||||
{
|
||||
var cancelPayload = new CancelPayload
|
||||
{
|
||||
Reason = reason ?? CancelReasons.Timeout
|
||||
};
|
||||
|
||||
return new Frame
|
||||
{
|
||||
Type = FrameType.Cancel,
|
||||
CorrelationId = correlationId,
|
||||
Payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(cancelPayload)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a frame with a specific payload size for testing limits.
|
||||
/// </summary>
|
||||
public static Frame CreateFrameWithPayloadSize(int payloadSize)
|
||||
{
|
||||
var payload = new byte[payloadSize];
|
||||
Random.Shared.NextBytes(payload);
|
||||
|
||||
return new Frame
|
||||
{
|
||||
Type = FrameType.Request,
|
||||
CorrelationId = Guid.NewGuid().ToString("N"),
|
||||
Payload = payload
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a request frame from JSON content.
|
||||
/// </summary>
|
||||
public static RequestFrame CreateTypedRequestFrame<T>(
|
||||
T request,
|
||||
string method = "POST",
|
||||
string path = "/test",
|
||||
Dictionary<string, string>? headers = null)
|
||||
{
|
||||
return new RequestFrame
|
||||
{
|
||||
RequestId = Guid.NewGuid().ToString("N"),
|
||||
CorrelationId = Guid.NewGuid().ToString("N"),
|
||||
Method = method,
|
||||
Path = path,
|
||||
Headers = headers ?? new Dictionary<string, string>(),
|
||||
Payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(request)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an endpoint descriptor for testing.
|
||||
/// </summary>
|
||||
public static EndpointDescriptor CreateEndpointDescriptor(
|
||||
string method = "GET",
|
||||
string path = "/test",
|
||||
string serviceName = "test-service",
|
||||
string version = "1.0.0",
|
||||
int timeoutSeconds = 30,
|
||||
bool supportsStreaming = false,
|
||||
IReadOnlyList<ClaimRequirement>? requiringClaims = null)
|
||||
{
|
||||
return new EndpointDescriptor
|
||||
{
|
||||
Method = method,
|
||||
Path = path,
|
||||
ServiceName = serviceName,
|
||||
Version = version,
|
||||
DefaultTimeout = TimeSpan.FromSeconds(timeoutSeconds),
|
||||
SupportsStreaming = supportsStreaming,
|
||||
RequiringClaims = requiringClaims ?? []
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an instance descriptor for testing.
|
||||
/// </summary>
|
||||
public static InstanceDescriptor CreateInstanceDescriptor(
|
||||
string instanceId = "test-instance",
|
||||
string serviceName = "test-service",
|
||||
string version = "1.0.0",
|
||||
string region = "test")
|
||||
{
|
||||
return new InstanceDescriptor
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
ServiceName = serviceName,
|
||||
Version = version,
|
||||
Region = region
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a claim requirement for testing.
|
||||
/// </summary>
|
||||
public static ClaimRequirement CreateClaimRequirement(
|
||||
string type,
|
||||
string? value = null)
|
||||
{
|
||||
return new ClaimRequirement
|
||||
{
|
||||
Type = type,
|
||||
Value = value
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Router.Testing.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Base test fixture for Router tests providing common utilities.
|
||||
/// Implements IAsyncLifetime for async setup/teardown.
|
||||
/// </summary>
|
||||
public abstract class RouterTestFixture : IAsyncLifetime
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a null logger factory for tests that don't need logging.
|
||||
/// </summary>
|
||||
protected ILoggerFactory LoggerFactory { get; } = NullLoggerFactory.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a null logger for tests that don't need logging.
|
||||
/// </summary>
|
||||
protected ILogger<T> GetLogger<T>() => NullLogger<T>.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a cancellation token that times out after the specified duration.
|
||||
/// </summary>
|
||||
protected static CancellationToken CreateTimeoutToken(TimeSpan timeout)
|
||||
{
|
||||
var cts = new CancellationTokenSource(timeout);
|
||||
return cts.Token;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a cancellation token that times out after 5 seconds (default for tests).
|
||||
/// </summary>
|
||||
protected static CancellationToken CreateTestTimeoutToken()
|
||||
{
|
||||
return CreateTimeoutToken(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for a condition to be true with timeout.
|
||||
/// </summary>
|
||||
protected static async Task WaitForConditionAsync(
|
||||
Func<bool> condition,
|
||||
TimeSpan timeout,
|
||||
TimeSpan? pollInterval = null)
|
||||
{
|
||||
var interval = pollInterval ?? TimeSpan.FromMilliseconds(50);
|
||||
var deadline = DateTimeOffset.UtcNow + timeout;
|
||||
|
||||
while (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
if (condition())
|
||||
return;
|
||||
|
||||
await Task.Delay(interval);
|
||||
}
|
||||
|
||||
throw new TimeoutException($"Condition not met within {timeout}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for an async condition to be true with timeout.
|
||||
/// </summary>
|
||||
protected static async Task WaitForConditionAsync(
|
||||
Func<Task<bool>> condition,
|
||||
TimeSpan timeout,
|
||||
TimeSpan? pollInterval = null)
|
||||
{
|
||||
var interval = pollInterval ?? TimeSpan.FromMilliseconds(50);
|
||||
var deadline = DateTimeOffset.UtcNow + timeout;
|
||||
|
||||
while (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
if (await condition())
|
||||
return;
|
||||
|
||||
await Task.Delay(interval);
|
||||
}
|
||||
|
||||
throw new TimeoutException($"Condition not met within {timeout}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override for async initialization.
|
||||
/// </summary>
|
||||
public virtual Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Override for async cleanup.
|
||||
/// </summary>
|
||||
public virtual Task DisposeAsync() => Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection fixture for sharing state across tests in the same collection.
|
||||
/// </summary>
|
||||
public abstract class RouterCollectionFixture : IAsyncLifetime
|
||||
{
|
||||
public virtual Task InitializeAsync() => Task.CompletedTask;
|
||||
public virtual Task DisposeAsync() => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Testing.Mocks;
|
||||
|
||||
/// <summary>
|
||||
/// A mock connection state for testing routing and connection management.
|
||||
/// </summary>
|
||||
public sealed class MockConnectionState
|
||||
{
|
||||
public string ConnectionId { get; init; } = Guid.NewGuid().ToString("N");
|
||||
public string ServiceName { get; init; } = "test-service";
|
||||
public string Version { get; init; } = "1.0.0";
|
||||
public string Region { get; init; } = "test";
|
||||
public string InstanceId { get; init; } = "test-instance";
|
||||
public InstanceHealthStatus HealthStatus { get; set; } = InstanceHealthStatus.Healthy;
|
||||
public DateTimeOffset ConnectedAtUtc { get; init; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset LastHeartbeatUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public int InflightRequests { get; set; }
|
||||
public int Weight { get; set; } = 100;
|
||||
public List<EndpointDescriptor> Endpoints { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a connection state for testing.
|
||||
/// </summary>
|
||||
public static MockConnectionState Create(
|
||||
string? serviceName = null,
|
||||
string? instanceId = null,
|
||||
InstanceHealthStatus status = InstanceHealthStatus.Healthy)
|
||||
{
|
||||
return new MockConnectionState
|
||||
{
|
||||
ServiceName = serviceName ?? "test-service",
|
||||
InstanceId = instanceId ?? $"instance-{Guid.NewGuid():N}",
|
||||
HealthStatus = status
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates multiple connection states simulating a service cluster.
|
||||
/// </summary>
|
||||
public static List<MockConnectionState> CreateCluster(
|
||||
string serviceName,
|
||||
int instanceCount,
|
||||
InstanceHealthStatus status = InstanceHealthStatus.Healthy)
|
||||
{
|
||||
return Enumerable.Range(0, instanceCount)
|
||||
.Select(i => new MockConnectionState
|
||||
{
|
||||
ServiceName = serviceName,
|
||||
InstanceId = $"{serviceName}-{i}",
|
||||
HealthStatus = status
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Router.Testing.Mocks;
|
||||
|
||||
/// <summary>
|
||||
/// A logger that records all log entries for assertions.
|
||||
/// </summary>
|
||||
public sealed class RecordingLogger<T> : ILogger<T>
|
||||
{
|
||||
private readonly ConcurrentQueue<LogEntry> _entries = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all recorded log entries.
|
||||
/// </summary>
|
||||
public IReadOnlyList<LogEntry> Entries => _entries.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Gets entries filtered by log level.
|
||||
/// </summary>
|
||||
public IEnumerable<LogEntry> GetEntries(LogLevel level) =>
|
||||
_entries.Where(e => e.Level == level);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all error entries.
|
||||
/// </summary>
|
||||
public IEnumerable<LogEntry> Errors => GetEntries(LogLevel.Error);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all warning entries.
|
||||
/// </summary>
|
||||
public IEnumerable<LogEntry> Warnings => GetEntries(LogLevel.Warning);
|
||||
|
||||
/// <summary>
|
||||
/// Clears all recorded entries.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
while (_entries.TryDequeue(out _)) { }
|
||||
}
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull =>
|
||||
NullScope.Instance;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
_entries.Enqueue(new LogEntry
|
||||
{
|
||||
Level = logLevel,
|
||||
EventId = eventId,
|
||||
Message = formatter(state, exception),
|
||||
Exception = exception,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static readonly NullScope Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a recorded log entry.
|
||||
/// </summary>
|
||||
public sealed record LogEntry
|
||||
{
|
||||
public required LogLevel Level { get; init; }
|
||||
public required EventId EventId { get; init; }
|
||||
public required string Message { get; init; }
|
||||
public Exception? Exception { get; init; }
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A logger factory that creates recording loggers.
|
||||
/// </summary>
|
||||
public sealed class RecordingLoggerFactory : ILoggerFactory
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, object> _loggers = new();
|
||||
|
||||
public ILogger CreateLogger(string categoryName) =>
|
||||
(ILogger)_loggers.GetOrAdd(categoryName, _ => new RecordingLogger<object>());
|
||||
|
||||
public ILogger<T> CreateLogger<T>() =>
|
||||
(ILogger<T>)_loggers.GetOrAdd(typeof(T).FullName!, _ => new RecordingLogger<T>());
|
||||
|
||||
public RecordingLogger<T>? GetLogger<T>() =>
|
||||
_loggers.TryGetValue(typeof(T).FullName!, out var logger)
|
||||
? logger as RecordingLogger<T>
|
||||
: null;
|
||||
|
||||
public void AddProvider(ILoggerProvider provider) { }
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>StellaOps.Router.Testing</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,300 @@
|
||||
using System.Threading.Channels;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Transport.InMemory.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="InMemoryChannel"/>.
|
||||
/// </summary>
|
||||
public sealed class InMemoryChannelTests
|
||||
{
|
||||
private static InstanceDescriptor CreateTestInstance()
|
||||
{
|
||||
return new InstanceDescriptor
|
||||
{
|
||||
InstanceId = "inst-456",
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0.0",
|
||||
Region = "default"
|
||||
};
|
||||
}
|
||||
|
||||
private static ConnectionState CreateTestConnectionState(string connectionId)
|
||||
{
|
||||
return new ConnectionState
|
||||
{
|
||||
ConnectionId = connectionId,
|
||||
Instance = CreateTestInstance(),
|
||||
TransportType = TransportType.InMemory
|
||||
};
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsConnectionId()
|
||||
{
|
||||
// Arrange & Act
|
||||
using var channel = new InMemoryChannel("conn-123");
|
||||
|
||||
// Assert
|
||||
channel.ConnectionId.Should().Be("conn-123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CreatesUnboundedChannels_ByDefault()
|
||||
{
|
||||
// Arrange & Act
|
||||
using var channel = new InMemoryChannel("conn-123");
|
||||
|
||||
// Assert - channels should be able to accept multiple items without blocking
|
||||
channel.ToMicroservice.Should().NotBeNull();
|
||||
channel.ToGateway.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CreatesBoundedChannels_WhenBufferSizeSpecified()
|
||||
{
|
||||
// Arrange & Act
|
||||
using var channel = new InMemoryChannel("conn-123", bufferSize: 10);
|
||||
|
||||
// Assert
|
||||
channel.ToMicroservice.Should().NotBeNull();
|
||||
channel.ToGateway.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CreatesLifetimeToken()
|
||||
{
|
||||
// Arrange & Act
|
||||
using var channel = new InMemoryChannel("conn-123");
|
||||
|
||||
// Assert
|
||||
channel.LifetimeToken.Should().NotBeNull();
|
||||
channel.LifetimeToken.IsCancellationRequested.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Instance_IsInitiallyNull()
|
||||
{
|
||||
// Arrange & Act
|
||||
using var channel = new InMemoryChannel("conn-123");
|
||||
|
||||
// Assert
|
||||
channel.Instance.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_State_IsInitiallyNull()
|
||||
{
|
||||
// Arrange & Act
|
||||
using var channel = new InMemoryChannel("conn-123");
|
||||
|
||||
// Assert
|
||||
channel.State.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Property Assignment Tests
|
||||
|
||||
[Fact]
|
||||
public void Instance_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
using var channel = new InMemoryChannel("conn-123");
|
||||
var instance = CreateTestInstance();
|
||||
|
||||
// Act
|
||||
channel.Instance = instance;
|
||||
|
||||
// Assert
|
||||
channel.Instance.Should().BeSameAs(instance);
|
||||
channel.Instance.ServiceName.Should().Be("test-service");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void State_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
using var channel = new InMemoryChannel("conn-123");
|
||||
var state = CreateTestConnectionState("conn-123");
|
||||
|
||||
// Act
|
||||
channel.State = state;
|
||||
|
||||
// Assert
|
||||
channel.State.Should().BeSameAs(state);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Channel Communication Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ToMicroservice_CanWriteAndRead()
|
||||
{
|
||||
// Arrange
|
||||
using var channel = new InMemoryChannel("conn-123");
|
||||
var frame = new Frame
|
||||
{
|
||||
Type = FrameType.Request,
|
||||
CorrelationId = "corr-123",
|
||||
Payload = new byte[] { 1, 2, 3 }
|
||||
};
|
||||
|
||||
// Act
|
||||
await channel.ToMicroservice.Writer.WriteAsync(frame);
|
||||
var received = await channel.ToMicroservice.Reader.ReadAsync();
|
||||
|
||||
// Assert
|
||||
received.Should().BeSameAs(frame);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToGateway_CanWriteAndRead()
|
||||
{
|
||||
// Arrange
|
||||
using var channel = new InMemoryChannel("conn-123");
|
||||
var frame = new Frame
|
||||
{
|
||||
Type = FrameType.Response,
|
||||
CorrelationId = "corr-123",
|
||||
Payload = new byte[] { 4, 5, 6 }
|
||||
};
|
||||
|
||||
// Act
|
||||
await channel.ToGateway.Writer.WriteAsync(frame);
|
||||
var received = await channel.ToGateway.Reader.ReadAsync();
|
||||
|
||||
// Assert
|
||||
received.Should().BeSameAs(frame);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Channel_MultipleFrames_DeliveredInOrder()
|
||||
{
|
||||
// Arrange
|
||||
using var channel = new InMemoryChannel("conn-123");
|
||||
var frame1 = new Frame { Type = FrameType.Request, CorrelationId = "1", Payload = Array.Empty<byte>() };
|
||||
var frame2 = new Frame { Type = FrameType.Request, CorrelationId = "2", Payload = Array.Empty<byte>() };
|
||||
var frame3 = new Frame { Type = FrameType.Request, CorrelationId = "3", Payload = Array.Empty<byte>() };
|
||||
|
||||
// Act
|
||||
await channel.ToMicroservice.Writer.WriteAsync(frame1);
|
||||
await channel.ToMicroservice.Writer.WriteAsync(frame2);
|
||||
await channel.ToMicroservice.Writer.WriteAsync(frame3);
|
||||
|
||||
var received1 = await channel.ToMicroservice.Reader.ReadAsync();
|
||||
var received2 = await channel.ToMicroservice.Reader.ReadAsync();
|
||||
var received3 = await channel.ToMicroservice.Reader.ReadAsync();
|
||||
|
||||
// Assert - FIFO ordering
|
||||
received1.CorrelationId.Should().Be("1");
|
||||
received2.CorrelationId.Should().Be("2");
|
||||
received3.CorrelationId.Should().Be("3");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Bounded Channel Tests
|
||||
|
||||
[Fact]
|
||||
public async Task BoundedChannel_AcceptsUpToBufferSize()
|
||||
{
|
||||
// Arrange
|
||||
using var channel = new InMemoryChannel("conn-123", bufferSize: 3);
|
||||
var frame = new Frame { Type = FrameType.Request, CorrelationId = "test", Payload = Array.Empty<byte>() };
|
||||
|
||||
// Act & Assert - should accept 3 without blocking
|
||||
await channel.ToMicroservice.Writer.WriteAsync(frame);
|
||||
await channel.ToMicroservice.Writer.WriteAsync(frame);
|
||||
await channel.ToMicroservice.Writer.WriteAsync(frame);
|
||||
|
||||
// Channel now at capacity
|
||||
var tryWrite = channel.ToMicroservice.Writer.TryWrite(frame);
|
||||
tryWrite.Should().BeFalse(); // Full, can't write synchronously
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Dispose Tests
|
||||
|
||||
[Fact]
|
||||
public void Dispose_CancelsLifetimeToken()
|
||||
{
|
||||
// Arrange
|
||||
var channel = new InMemoryChannel("conn-123");
|
||||
|
||||
// Act
|
||||
channel.Dispose();
|
||||
|
||||
// Assert
|
||||
channel.LifetimeToken.IsCancellationRequested.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_CompletesChannels()
|
||||
{
|
||||
// Arrange
|
||||
var channel = new InMemoryChannel("conn-123");
|
||||
|
||||
// Act
|
||||
channel.Dispose();
|
||||
|
||||
// Assert
|
||||
channel.ToMicroservice.Reader.Completion.IsCompleted.Should().BeTrue();
|
||||
channel.ToGateway.Reader.Completion.IsCompleted.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_CanBeCalledMultipleTimes()
|
||||
{
|
||||
// Arrange
|
||||
var channel = new InMemoryChannel("conn-123");
|
||||
|
||||
// Act
|
||||
var action = () =>
|
||||
{
|
||||
channel.Dispose();
|
||||
channel.Dispose();
|
||||
channel.Dispose();
|
||||
};
|
||||
|
||||
// Assert
|
||||
action.Should().NotThrow();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_ReaderDetectsCompletion()
|
||||
{
|
||||
// Arrange
|
||||
using var channel = new InMemoryChannel("conn-123");
|
||||
|
||||
// Start reader task
|
||||
var readerTask = Task.Run(async () =>
|
||||
{
|
||||
var completed = false;
|
||||
try
|
||||
{
|
||||
await channel.ToMicroservice.Reader.ReadAsync();
|
||||
}
|
||||
catch (ChannelClosedException)
|
||||
{
|
||||
completed = true;
|
||||
}
|
||||
return completed;
|
||||
});
|
||||
|
||||
// Act
|
||||
await Task.Delay(50); // Give reader time to start waiting
|
||||
channel.Dispose();
|
||||
|
||||
// Assert
|
||||
var result = await readerTask;
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Transport.InMemory.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="InMemoryConnectionRegistry"/>.
|
||||
/// </summary>
|
||||
public sealed class InMemoryConnectionRegistryTests : IDisposable
|
||||
{
|
||||
private readonly InMemoryConnectionRegistry _registry;
|
||||
|
||||
public InMemoryConnectionRegistryTests()
|
||||
{
|
||||
_registry = new InMemoryConnectionRegistry();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_registry.Dispose();
|
||||
}
|
||||
|
||||
private static InstanceDescriptor CreateTestInstance(string instanceId = "inst-1", string serviceName = "test-service", string version = "1.0")
|
||||
{
|
||||
return new InstanceDescriptor
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
ServiceName = serviceName,
|
||||
Version = version,
|
||||
Region = "default"
|
||||
};
|
||||
}
|
||||
|
||||
private static ConnectionState CreateTestConnectionState(string connectionId)
|
||||
{
|
||||
return new ConnectionState
|
||||
{
|
||||
ConnectionId = connectionId,
|
||||
Instance = CreateTestInstance(),
|
||||
TransportType = TransportType.InMemory
|
||||
};
|
||||
}
|
||||
|
||||
#region CreateChannel Tests
|
||||
|
||||
[Fact]
|
||||
public void CreateChannel_ReturnsNewChannel()
|
||||
{
|
||||
// Arrange & Act
|
||||
var channel = _registry.CreateChannel("conn-123");
|
||||
|
||||
// Assert
|
||||
channel.Should().NotBeNull();
|
||||
channel.ConnectionId.Should().Be("conn-123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateChannel_IncreasesCount()
|
||||
{
|
||||
// Arrange
|
||||
_registry.Count.Should().Be(0);
|
||||
|
||||
// Act
|
||||
_registry.CreateChannel("conn-123");
|
||||
|
||||
// Assert
|
||||
_registry.Count.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateChannel_WithBufferSize_CreatesCorrectChannel()
|
||||
{
|
||||
// Arrange & Act
|
||||
var channel = _registry.CreateChannel("conn-123", bufferSize: 100);
|
||||
|
||||
// Assert
|
||||
channel.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateChannel_DuplicateId_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
_registry.CreateChannel("conn-123");
|
||||
|
||||
// Act
|
||||
var action = () => _registry.CreateChannel("conn-123");
|
||||
|
||||
// Assert
|
||||
action.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*conn-123*already exists*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateChannel_AfterDispose_ThrowsObjectDisposedException()
|
||||
{
|
||||
// Arrange
|
||||
_registry.Dispose();
|
||||
|
||||
// Act
|
||||
var action = () => _registry.CreateChannel("conn-123");
|
||||
|
||||
// Assert
|
||||
action.Should().Throw<ObjectDisposedException>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetChannel Tests
|
||||
|
||||
[Fact]
|
||||
public void GetChannel_ExistingConnection_ReturnsChannel()
|
||||
{
|
||||
// Arrange
|
||||
var created = _registry.CreateChannel("conn-123");
|
||||
|
||||
// Act
|
||||
var retrieved = _registry.GetChannel("conn-123");
|
||||
|
||||
// Assert
|
||||
retrieved.Should().BeSameAs(created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetChannel_NonexistentConnection_ReturnsNull()
|
||||
{
|
||||
// Arrange & Act
|
||||
var retrieved = _registry.GetChannel("nonexistent");
|
||||
|
||||
// Assert
|
||||
retrieved.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetRequiredChannel Tests
|
||||
|
||||
[Fact]
|
||||
public void GetRequiredChannel_ExistingConnection_ReturnsChannel()
|
||||
{
|
||||
// Arrange
|
||||
var created = _registry.CreateChannel("conn-123");
|
||||
|
||||
// Act
|
||||
var retrieved = _registry.GetRequiredChannel("conn-123");
|
||||
|
||||
// Assert
|
||||
retrieved.Should().BeSameAs(created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRequiredChannel_NonexistentConnection_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange & Act
|
||||
var action = () => _registry.GetRequiredChannel("nonexistent");
|
||||
|
||||
// Assert
|
||||
action.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*nonexistent*not found*");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RemoveChannel Tests
|
||||
|
||||
[Fact]
|
||||
public void RemoveChannel_ExistingConnection_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
_registry.CreateChannel("conn-123");
|
||||
|
||||
// Act
|
||||
var result = _registry.RemoveChannel("conn-123");
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
_registry.Count.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveChannel_NonexistentConnection_ReturnsFalse()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = _registry.RemoveChannel("nonexistent");
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveChannel_DisposesChannel()
|
||||
{
|
||||
// Arrange
|
||||
var channel = _registry.CreateChannel("conn-123");
|
||||
var token = channel.LifetimeToken;
|
||||
|
||||
// Act
|
||||
_registry.RemoveChannel("conn-123");
|
||||
|
||||
// Assert
|
||||
token.IsCancellationRequested.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveChannel_CannotGetAfterRemove()
|
||||
{
|
||||
// Arrange
|
||||
_registry.CreateChannel("conn-123");
|
||||
|
||||
// Act
|
||||
_registry.RemoveChannel("conn-123");
|
||||
var retrieved = _registry.GetChannel("conn-123");
|
||||
|
||||
// Assert
|
||||
retrieved.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ConnectionIds Tests
|
||||
|
||||
[Fact]
|
||||
public void ConnectionIds_EmptyRegistry_ReturnsEmpty()
|
||||
{
|
||||
// Arrange & Act
|
||||
var ids = _registry.ConnectionIds;
|
||||
|
||||
// Assert
|
||||
ids.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConnectionIds_WithConnections_ReturnsAllIds()
|
||||
{
|
||||
// Arrange
|
||||
_registry.CreateChannel("conn-1");
|
||||
_registry.CreateChannel("conn-2");
|
||||
_registry.CreateChannel("conn-3");
|
||||
|
||||
// Act
|
||||
var ids = _registry.ConnectionIds.ToList();
|
||||
|
||||
// Assert
|
||||
ids.Should().HaveCount(3);
|
||||
ids.Should().Contain("conn-1");
|
||||
ids.Should().Contain("conn-2");
|
||||
ids.Should().Contain("conn-3");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Count Tests
|
||||
|
||||
[Fact]
|
||||
public void Count_EmptyRegistry_IsZero()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
_registry.Count.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Count_ReflectsActiveConnections()
|
||||
{
|
||||
// Arrange & Act
|
||||
_registry.CreateChannel("conn-1");
|
||||
_registry.CreateChannel("conn-2");
|
||||
_registry.Count.Should().Be(2);
|
||||
|
||||
_registry.RemoveChannel("conn-1");
|
||||
_registry.Count.Should().Be(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetAllConnections Tests
|
||||
|
||||
[Fact]
|
||||
public void GetAllConnections_EmptyRegistry_ReturnsEmpty()
|
||||
{
|
||||
// Arrange & Act
|
||||
var connections = _registry.GetAllConnections();
|
||||
|
||||
// Assert
|
||||
connections.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllConnections_ChannelsWithoutState_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
_registry.CreateChannel("conn-1");
|
||||
_registry.CreateChannel("conn-2");
|
||||
|
||||
// Act
|
||||
var connections = _registry.GetAllConnections();
|
||||
|
||||
// Assert
|
||||
connections.Should().BeEmpty(); // No State set on channels
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllConnections_ChannelsWithState_ReturnsStates()
|
||||
{
|
||||
// Arrange
|
||||
var channel1 = _registry.CreateChannel("conn-1");
|
||||
channel1.State = CreateTestConnectionState("conn-1");
|
||||
|
||||
var channel2 = _registry.CreateChannel("conn-2");
|
||||
channel2.State = CreateTestConnectionState("conn-2");
|
||||
|
||||
// Act
|
||||
var connections = _registry.GetAllConnections();
|
||||
|
||||
// Assert
|
||||
connections.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetConnectionsFor Tests
|
||||
|
||||
[Fact]
|
||||
public void GetConnectionsFor_NoMatchingConnections_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
_registry.CreateChannel("conn-1");
|
||||
|
||||
// Act
|
||||
var connections = _registry.GetConnectionsFor("test-service", "1.0", "GET", "/api/users");
|
||||
|
||||
// Assert
|
||||
connections.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetConnectionsFor_MatchingServiceAndEndpoint_ReturnsConnections()
|
||||
{
|
||||
// Arrange
|
||||
var channel = _registry.CreateChannel("conn-1");
|
||||
channel.Instance = CreateTestInstance("inst-1", "test-service", "1.0");
|
||||
channel.State = CreateTestConnectionState("conn-1");
|
||||
channel.State.Endpoints[("GET", "/api/users")] = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0",
|
||||
Method = "GET",
|
||||
Path = "/api/users"
|
||||
};
|
||||
|
||||
// Act
|
||||
var connections = _registry.GetConnectionsFor("test-service", "1.0", "GET", "/api/users");
|
||||
|
||||
// Assert
|
||||
connections.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetConnectionsFor_MismatchedVersion_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var channel = _registry.CreateChannel("conn-1");
|
||||
channel.Instance = CreateTestInstance("inst-1", "test-service", "1.0");
|
||||
channel.State = CreateTestConnectionState("conn-1");
|
||||
channel.State.Endpoints[("GET", "/api/users")] = new EndpointDescriptor
|
||||
{
|
||||
ServiceName = "test-service",
|
||||
Version = "1.0",
|
||||
Method = "GET",
|
||||
Path = "/api/users"
|
||||
};
|
||||
|
||||
// Act
|
||||
var connections = _registry.GetConnectionsFor("test-service", "2.0", "GET", "/api/users");
|
||||
|
||||
// Assert
|
||||
connections.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Dispose Tests
|
||||
|
||||
[Fact]
|
||||
public void Dispose_DisposesAllChannels()
|
||||
{
|
||||
// Arrange
|
||||
var channel1 = _registry.CreateChannel("conn-1");
|
||||
var channel2 = _registry.CreateChannel("conn-2");
|
||||
var token1 = channel1.LifetimeToken;
|
||||
var token2 = channel2.LifetimeToken;
|
||||
|
||||
// Act
|
||||
_registry.Dispose();
|
||||
|
||||
// Assert
|
||||
token1.IsCancellationRequested.Should().BeTrue();
|
||||
token2.IsCancellationRequested.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_ClearsRegistry()
|
||||
{
|
||||
// Arrange
|
||||
_registry.CreateChannel("conn-1");
|
||||
_registry.CreateChannel("conn-2");
|
||||
|
||||
// Act
|
||||
_registry.Dispose();
|
||||
|
||||
// Assert - Count may not be accurate after dispose, but GetChannel should not work
|
||||
// We need a separate test for post-dispose behavior
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_CanBeCalledMultipleTimes()
|
||||
{
|
||||
// Arrange
|
||||
_registry.CreateChannel("conn-1");
|
||||
|
||||
// Act
|
||||
var action = () =>
|
||||
{
|
||||
_registry.Dispose();
|
||||
_registry.Dispose();
|
||||
_registry.Dispose();
|
||||
};
|
||||
|
||||
// Assert
|
||||
action.Should().NotThrow();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Concurrency Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentOperations_ThreadSafe()
|
||||
{
|
||||
// Arrange
|
||||
var tasks = new List<Task>();
|
||||
var connectionCount = 100;
|
||||
|
||||
// Act - Create and remove channels concurrently
|
||||
for (int i = 0; i < connectionCount; i++)
|
||||
{
|
||||
var id = $"conn-{i}";
|
||||
tasks.Add(Task.Run(() => _registry.CreateChannel(id)));
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// Assert
|
||||
_registry.Count.Should().Be(connectionCount);
|
||||
_registry.ConnectionIds.Should().HaveCount(connectionCount);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
namespace StellaOps.Router.Transport.InMemory.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="InMemoryTransportOptions"/>.
|
||||
/// </summary>
|
||||
public sealed class InMemoryTransportOptionsTests
|
||||
{
|
||||
#region Default Values Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DefaultTimeout_Is30Seconds()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new InMemoryTransportOptions();
|
||||
|
||||
// Assert
|
||||
options.DefaultTimeout.Should().Be(TimeSpan.FromSeconds(30));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SimulatedLatency_IsZero()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new InMemoryTransportOptions();
|
||||
|
||||
// Assert
|
||||
options.SimulatedLatency.Should().Be(TimeSpan.Zero);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ChannelBufferSize_IsZero()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new InMemoryTransportOptions();
|
||||
|
||||
// Assert
|
||||
options.ChannelBufferSize.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_HeartbeatInterval_Is10Seconds()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new InMemoryTransportOptions();
|
||||
|
||||
// Assert
|
||||
options.HeartbeatInterval.Should().Be(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_HeartbeatTimeout_Is30Seconds()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new InMemoryTransportOptions();
|
||||
|
||||
// Assert
|
||||
options.HeartbeatTimeout.Should().Be(TimeSpan.FromSeconds(30));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Property Assignment Tests
|
||||
|
||||
[Fact]
|
||||
public void DefaultTimeout_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var options = new InMemoryTransportOptions();
|
||||
|
||||
// Act
|
||||
options.DefaultTimeout = TimeSpan.FromMinutes(5);
|
||||
|
||||
// Assert
|
||||
options.DefaultTimeout.Should().Be(TimeSpan.FromMinutes(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SimulatedLatency_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var options = new InMemoryTransportOptions();
|
||||
|
||||
// Act
|
||||
options.SimulatedLatency = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
// Assert
|
||||
options.SimulatedLatency.Should().Be(TimeSpan.FromMilliseconds(100));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(100)]
|
||||
[InlineData(1000)]
|
||||
[InlineData(10000)]
|
||||
public void ChannelBufferSize_CanBeSet(int bufferSize)
|
||||
{
|
||||
// Arrange
|
||||
var options = new InMemoryTransportOptions();
|
||||
|
||||
// Act
|
||||
options.ChannelBufferSize = bufferSize;
|
||||
|
||||
// Assert
|
||||
options.ChannelBufferSize.Should().Be(bufferSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeartbeatInterval_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var options = new InMemoryTransportOptions();
|
||||
|
||||
// Act
|
||||
options.HeartbeatInterval = TimeSpan.FromSeconds(5);
|
||||
|
||||
// Assert
|
||||
options.HeartbeatInterval.Should().Be(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeartbeatTimeout_CanBeSet()
|
||||
{
|
||||
// Arrange
|
||||
var options = new InMemoryTransportOptions();
|
||||
|
||||
// Act
|
||||
options.HeartbeatTimeout = TimeSpan.FromMinutes(1);
|
||||
|
||||
// Assert
|
||||
options.HeartbeatTimeout.Should().Be(TimeSpan.FromMinutes(1));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Typical Configuration Tests
|
||||
|
||||
[Fact]
|
||||
public void TypicalConfiguration_DevelopmentEnvironment()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new InMemoryTransportOptions
|
||||
{
|
||||
DefaultTimeout = TimeSpan.FromMinutes(5), // Longer timeout for debugging
|
||||
SimulatedLatency = TimeSpan.Zero, // Instant for development
|
||||
ChannelBufferSize = 0, // Unbounded
|
||||
HeartbeatInterval = TimeSpan.FromSeconds(30),
|
||||
HeartbeatTimeout = TimeSpan.FromMinutes(5)
|
||||
};
|
||||
|
||||
// Assert
|
||||
options.DefaultTimeout.Should().Be(TimeSpan.FromMinutes(5));
|
||||
options.SimulatedLatency.Should().Be(TimeSpan.Zero);
|
||||
options.ChannelBufferSize.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TypicalConfiguration_TestingWithSimulatedLatency()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new InMemoryTransportOptions
|
||||
{
|
||||
DefaultTimeout = TimeSpan.FromSeconds(60),
|
||||
SimulatedLatency = TimeSpan.FromMilliseconds(50), // Simulate network latency
|
||||
ChannelBufferSize = 100, // Bounded for testing backpressure
|
||||
HeartbeatInterval = TimeSpan.FromSeconds(5),
|
||||
HeartbeatTimeout = TimeSpan.FromSeconds(15)
|
||||
};
|
||||
|
||||
// Assert
|
||||
options.SimulatedLatency.Should().Be(TimeSpan.FromMilliseconds(50));
|
||||
options.ChannelBufferSize.Should().Be(100);
|
||||
options.HeartbeatTimeout.Should().BeGreaterThan(options.HeartbeatInterval);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<!-- Suppress CA2255 from OpenSSL auto-init shim included via Directory.Build.props -->
|
||||
<NoWarn>$(NoWarn);CA2255</NoWarn>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>StellaOps.Router.Transport.InMemory.Tests</RootNamespace>
|
||||
<!-- Disable Concelier test infrastructure (Mongo2Go, etc.) since not needed for InMemory tests -->
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Test SDK packages come from Directory.Build.props -->
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Router.Transport.InMemory\StellaOps.Router.Transport.InMemory.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Router.Testing\StellaOps.Router.Testing.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,436 @@
|
||||
using RabbitMQ.Client;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
|
||||
namespace StellaOps.Router.Transport.RabbitMq.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="RabbitMqFrameProtocol"/>.
|
||||
/// </summary>
|
||||
public sealed class RabbitMqFrameProtocolTests
|
||||
{
|
||||
#region ParseFrame Tests
|
||||
|
||||
[Fact]
|
||||
public void ParseFrame_WithValidProperties_ReturnsFrame()
|
||||
{
|
||||
// Arrange
|
||||
var body = new byte[] { 1, 2, 3, 4, 5 };
|
||||
var properties = new StubBasicProperties
|
||||
{
|
||||
Type = "Request",
|
||||
CorrelationId = "test-correlation-id"
|
||||
};
|
||||
|
||||
// Act
|
||||
var frame = RabbitMqFrameProtocol.ParseFrame(body, properties);
|
||||
|
||||
// Assert
|
||||
frame.Type.Should().Be(FrameType.Request);
|
||||
frame.CorrelationId.Should().Be("test-correlation-id");
|
||||
frame.Payload.ToArray().Should().BeEquivalentTo(body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFrame_WithResponseType_ReturnsResponseFrame()
|
||||
{
|
||||
// Arrange
|
||||
var body = new byte[] { 1, 2 };
|
||||
var properties = new StubBasicProperties { Type = "Response", CorrelationId = "resp-123" };
|
||||
|
||||
// Act
|
||||
var frame = RabbitMqFrameProtocol.ParseFrame(body, properties);
|
||||
|
||||
// Assert
|
||||
frame.Type.Should().Be(FrameType.Response);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFrame_WithHelloType_ReturnsHelloFrame()
|
||||
{
|
||||
// Arrange
|
||||
var body = Array.Empty<byte>();
|
||||
var properties = new StubBasicProperties { Type = "Hello", CorrelationId = "hello-123" };
|
||||
|
||||
// Act
|
||||
var frame = RabbitMqFrameProtocol.ParseFrame(body, properties);
|
||||
|
||||
// Assert
|
||||
frame.Type.Should().Be(FrameType.Hello);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFrame_WithHeartbeatType_ReturnsHeartbeatFrame()
|
||||
{
|
||||
// Arrange
|
||||
var body = Array.Empty<byte>();
|
||||
var properties = new StubBasicProperties { Type = "Heartbeat", CorrelationId = "hb-123" };
|
||||
|
||||
// Act
|
||||
var frame = RabbitMqFrameProtocol.ParseFrame(body, properties);
|
||||
|
||||
// Assert
|
||||
frame.Type.Should().Be(FrameType.Heartbeat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFrame_WithCancelType_ReturnsCancelFrame()
|
||||
{
|
||||
// Arrange
|
||||
var body = Array.Empty<byte>();
|
||||
var properties = new StubBasicProperties { Type = "Cancel", CorrelationId = "cancel-123" };
|
||||
|
||||
// Act
|
||||
var frame = RabbitMqFrameProtocol.ParseFrame(body, properties);
|
||||
|
||||
// Assert
|
||||
frame.Type.Should().Be(FrameType.Cancel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFrame_WithNullType_DefaultsToRequest()
|
||||
{
|
||||
// Arrange
|
||||
var body = new byte[] { 1 };
|
||||
var properties = new StubBasicProperties { Type = null, CorrelationId = "test" };
|
||||
|
||||
// Act
|
||||
var frame = RabbitMqFrameProtocol.ParseFrame(body, properties);
|
||||
|
||||
// Assert
|
||||
frame.Type.Should().Be(FrameType.Request);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFrame_WithEmptyType_DefaultsToRequest()
|
||||
{
|
||||
// Arrange
|
||||
var body = new byte[] { 1 };
|
||||
var properties = new StubBasicProperties { Type = "", CorrelationId = "test" };
|
||||
|
||||
// Act
|
||||
var frame = RabbitMqFrameProtocol.ParseFrame(body, properties);
|
||||
|
||||
// Assert
|
||||
frame.Type.Should().Be(FrameType.Request);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFrame_WithInvalidType_DefaultsToRequest()
|
||||
{
|
||||
// Arrange
|
||||
var body = new byte[] { 1 };
|
||||
var properties = new StubBasicProperties { Type = "InvalidType", CorrelationId = "test" };
|
||||
|
||||
// Act
|
||||
var frame = RabbitMqFrameProtocol.ParseFrame(body, properties);
|
||||
|
||||
// Assert
|
||||
frame.Type.Should().Be(FrameType.Request);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFrame_CaseInsensitive_ParsesType()
|
||||
{
|
||||
// Arrange
|
||||
var body = new byte[] { 1 };
|
||||
var properties = new StubBasicProperties { Type = "rEsPoNsE", CorrelationId = "test" };
|
||||
|
||||
// Act
|
||||
var frame = RabbitMqFrameProtocol.ParseFrame(body, properties);
|
||||
|
||||
// Assert
|
||||
frame.Type.Should().Be(FrameType.Response);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CreateProperties Tests
|
||||
|
||||
[Fact]
|
||||
public void CreateProperties_WithFrame_SetsTypeProperty()
|
||||
{
|
||||
// Arrange
|
||||
var frame = new Frame
|
||||
{
|
||||
Type = FrameType.Response,
|
||||
CorrelationId = "test-123",
|
||||
Payload = Array.Empty<byte>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var properties = RabbitMqFrameProtocol.CreateProperties(frame, null);
|
||||
|
||||
// Assert
|
||||
properties.Type.Should().Be("Response");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProperties_WithCorrelationId_SetsCorrelationId()
|
||||
{
|
||||
// Arrange
|
||||
var frame = new Frame
|
||||
{
|
||||
Type = FrameType.Request,
|
||||
CorrelationId = "my-correlation-id",
|
||||
Payload = Array.Empty<byte>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var properties = RabbitMqFrameProtocol.CreateProperties(frame, null);
|
||||
|
||||
// Assert
|
||||
properties.CorrelationId.Should().Be("my-correlation-id");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProperties_WithReplyTo_SetsReplyTo()
|
||||
{
|
||||
// Arrange
|
||||
var frame = new Frame
|
||||
{
|
||||
Type = FrameType.Request,
|
||||
CorrelationId = "test",
|
||||
Payload = Array.Empty<byte>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var properties = RabbitMqFrameProtocol.CreateProperties(frame, "my-reply-queue");
|
||||
|
||||
// Assert
|
||||
properties.ReplyTo.Should().Be("my-reply-queue");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProperties_WithNullReplyTo_DoesNotSetReplyTo()
|
||||
{
|
||||
// Arrange
|
||||
var frame = new Frame
|
||||
{
|
||||
Type = FrameType.Request,
|
||||
CorrelationId = "test",
|
||||
Payload = Array.Empty<byte>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var properties = RabbitMqFrameProtocol.CreateProperties(frame, null);
|
||||
|
||||
// Assert
|
||||
properties.ReplyTo.Should().BeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProperties_WithTimeout_SetsExpiration()
|
||||
{
|
||||
// Arrange
|
||||
var frame = new Frame
|
||||
{
|
||||
Type = FrameType.Request,
|
||||
CorrelationId = "test",
|
||||
Payload = Array.Empty<byte>()
|
||||
};
|
||||
var timeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
// Act
|
||||
var properties = RabbitMqFrameProtocol.CreateProperties(frame, null, timeout);
|
||||
|
||||
// Assert
|
||||
properties.Expiration.Should().Be("30000");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProperties_WithoutTimeout_DoesNotSetExpiration()
|
||||
{
|
||||
// Arrange
|
||||
var frame = new Frame
|
||||
{
|
||||
Type = FrameType.Request,
|
||||
CorrelationId = "test",
|
||||
Payload = Array.Empty<byte>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var properties = RabbitMqFrameProtocol.CreateProperties(frame, null, null);
|
||||
|
||||
// Assert
|
||||
properties.Expiration.Should().BeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProperties_SetsTimestamp()
|
||||
{
|
||||
// Arrange
|
||||
var frame = new Frame
|
||||
{
|
||||
Type = FrameType.Request,
|
||||
CorrelationId = "test",
|
||||
Payload = Array.Empty<byte>()
|
||||
};
|
||||
var beforeTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
|
||||
// Act
|
||||
var properties = RabbitMqFrameProtocol.CreateProperties(frame, null);
|
||||
var afterTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
|
||||
// Assert
|
||||
properties.Timestamp.UnixTime.Should().BeInRange(beforeTimestamp, afterTimestamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProperties_SetsTransientDeliveryMode()
|
||||
{
|
||||
// Arrange
|
||||
var frame = new Frame
|
||||
{
|
||||
Type = FrameType.Request,
|
||||
CorrelationId = "test",
|
||||
Payload = Array.Empty<byte>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var properties = RabbitMqFrameProtocol.CreateProperties(frame, null);
|
||||
|
||||
// Assert
|
||||
properties.DeliveryMode.Should().Be(DeliveryModes.Transient);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ExtractConnectionId Tests
|
||||
|
||||
[Fact]
|
||||
public void ExtractConnectionId_WithReplyTo_ExtractsFromQueueName()
|
||||
{
|
||||
// Arrange
|
||||
var properties = new StubBasicProperties
|
||||
{
|
||||
Type = "Hello",
|
||||
CorrelationId = "test",
|
||||
ReplyTo = "stella.svc.instance-123"
|
||||
};
|
||||
|
||||
// Act
|
||||
var connectionId = RabbitMqFrameProtocol.ExtractConnectionId(properties);
|
||||
|
||||
// Assert
|
||||
connectionId.Should().Be("rmq-instance-123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractConnectionId_WithSimpleReplyTo_PrefixesWithRmq()
|
||||
{
|
||||
// Arrange
|
||||
var properties = new StubBasicProperties
|
||||
{
|
||||
Type = "Hello",
|
||||
CorrelationId = "test",
|
||||
ReplyTo = "simple-queue"
|
||||
};
|
||||
|
||||
// Act
|
||||
var connectionId = RabbitMqFrameProtocol.ExtractConnectionId(properties);
|
||||
|
||||
// Assert
|
||||
connectionId.Should().Be("rmq-simple-queue");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractConnectionId_WithoutReplyTo_UsesCorrelationId()
|
||||
{
|
||||
// Arrange
|
||||
var properties = new StubBasicProperties
|
||||
{
|
||||
Type = "Hello",
|
||||
CorrelationId = "abcd1234567890efgh",
|
||||
ReplyTo = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var connectionId = RabbitMqFrameProtocol.ExtractConnectionId(properties);
|
||||
|
||||
// Assert
|
||||
connectionId.Should().StartWith("rmq-");
|
||||
connectionId.Should().Contain("abcd1234567890ef");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractConnectionId_WithShortCorrelationId_UsesEntireId()
|
||||
{
|
||||
// Arrange
|
||||
var properties = new StubBasicProperties
|
||||
{
|
||||
Type = "Hello",
|
||||
CorrelationId = "short",
|
||||
ReplyTo = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var connectionId = RabbitMqFrameProtocol.ExtractConnectionId(properties);
|
||||
|
||||
// Assert
|
||||
connectionId.Should().Be("rmq-short");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractConnectionId_WithNoIdentifiers_GeneratesGuid()
|
||||
{
|
||||
// Arrange
|
||||
var properties = new StubBasicProperties
|
||||
{
|
||||
Type = "Hello",
|
||||
CorrelationId = null,
|
||||
ReplyTo = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var connectionId = RabbitMqFrameProtocol.ExtractConnectionId(properties);
|
||||
|
||||
// Assert
|
||||
connectionId.Should().StartWith("rmq-");
|
||||
connectionId.Length.Should().Be(32);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Stub Implementation
|
||||
|
||||
/// <summary>
|
||||
/// Stub implementation of IReadOnlyBasicProperties for testing.
|
||||
/// </summary>
|
||||
private sealed class StubBasicProperties : IReadOnlyBasicProperties
|
||||
{
|
||||
public string? AppId { get; init; }
|
||||
public string? ClusterId { get; init; }
|
||||
public string? ContentEncoding { get; init; }
|
||||
public string? ContentType { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
public DeliveryModes DeliveryMode { get; init; }
|
||||
public string? Expiration { get; init; }
|
||||
public IDictionary<string, object?>? Headers { get; init; }
|
||||
public string? MessageId { get; init; }
|
||||
public bool Persistent { get; init; }
|
||||
public byte Priority { get; init; }
|
||||
public string? ReplyTo { get; init; }
|
||||
public PublicationAddress? ReplyToAddress { get; init; }
|
||||
public AmqpTimestamp Timestamp { get; init; }
|
||||
public string? Type { get; init; }
|
||||
public string? UserId { get; init; }
|
||||
|
||||
public bool IsAppIdPresent() => AppId != null;
|
||||
public bool IsClusterIdPresent() => ClusterId != null;
|
||||
public bool IsContentEncodingPresent() => ContentEncoding != null;
|
||||
public bool IsContentTypePresent() => ContentType != null;
|
||||
public bool IsCorrelationIdPresent() => CorrelationId != null;
|
||||
public bool IsDeliveryModePresent() => true;
|
||||
public bool IsExpirationPresent() => Expiration != null;
|
||||
public bool IsHeadersPresent() => Headers != null;
|
||||
public bool IsMessageIdPresent() => MessageId != null;
|
||||
public bool IsPriorityPresent() => true;
|
||||
public bool IsReplyToPresent() => ReplyTo != null;
|
||||
public bool IsTimestampPresent() => true;
|
||||
public bool IsTypePresent() => Type != null;
|
||||
public bool IsUserIdPresent() => UserId != null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
namespace StellaOps.Router.Transport.RabbitMq.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="RabbitMqTransportOptions"/>.
|
||||
/// </summary>
|
||||
public sealed class RabbitMqTransportOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultOptions_HostName_IsLocalhost()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RabbitMqTransportOptions();
|
||||
|
||||
// Assert
|
||||
options.HostName.Should().Be("localhost");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultOptions_Port_Is5672()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RabbitMqTransportOptions();
|
||||
|
||||
// Assert
|
||||
options.Port.Should().Be(5672);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultOptions_VirtualHost_IsRoot()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RabbitMqTransportOptions();
|
||||
|
||||
// Assert
|
||||
options.VirtualHost.Should().Be("/");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultOptions_UserName_IsGuest()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RabbitMqTransportOptions();
|
||||
|
||||
// Assert
|
||||
options.UserName.Should().Be("guest");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultOptions_Password_IsGuest()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RabbitMqTransportOptions();
|
||||
|
||||
// Assert
|
||||
options.Password.Should().Be("guest");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultOptions_UseSsl_IsFalse()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RabbitMqTransportOptions();
|
||||
|
||||
// Assert
|
||||
options.UseSsl.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultOptions_SslCertPath_IsNull()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RabbitMqTransportOptions();
|
||||
|
||||
// Assert
|
||||
options.SslCertPath.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultOptions_DurableQueues_IsFalse()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RabbitMqTransportOptions();
|
||||
|
||||
// Assert
|
||||
options.DurableQueues.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultOptions_AutoDeleteQueues_IsTrue()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RabbitMqTransportOptions();
|
||||
|
||||
// Assert
|
||||
options.AutoDeleteQueues.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultOptions_PrefetchCount_Is10()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RabbitMqTransportOptions();
|
||||
|
||||
// Assert
|
||||
options.PrefetchCount.Should().Be(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultOptions_ExchangePrefix_IsStellaRouter()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RabbitMqTransportOptions();
|
||||
|
||||
// Assert
|
||||
options.ExchangePrefix.Should().Be("stella.router");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultOptions_QueuePrefix_IsStella()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RabbitMqTransportOptions();
|
||||
|
||||
// Assert
|
||||
options.QueuePrefix.Should().Be("stella");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequestExchange_UsesExchangePrefix()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RabbitMqTransportOptions
|
||||
{
|
||||
ExchangePrefix = "custom.prefix"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
options.RequestExchange.Should().Be("custom.prefix.requests");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResponseExchange_UsesExchangePrefix()
|
||||
{
|
||||
// Arrange
|
||||
var options = new RabbitMqTransportOptions
|
||||
{
|
||||
ExchangePrefix = "custom.prefix"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
options.ResponseExchange.Should().Be("custom.prefix.responses");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultOptions_NodeId_IsNull()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RabbitMqTransportOptions();
|
||||
|
||||
// Assert
|
||||
options.NodeId.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultOptions_InstanceId_IsNull()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RabbitMqTransportOptions();
|
||||
|
||||
// Assert
|
||||
options.InstanceId.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultOptions_AutomaticRecoveryEnabled_IsTrue()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RabbitMqTransportOptions();
|
||||
|
||||
// Assert
|
||||
options.AutomaticRecoveryEnabled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultOptions_NetworkRecoveryInterval_Is5Seconds()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RabbitMqTransportOptions();
|
||||
|
||||
// Assert
|
||||
options.NetworkRecoveryInterval.Should().Be(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultOptions_DefaultTimeout_Is30Seconds()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RabbitMqTransportOptions();
|
||||
|
||||
// Assert
|
||||
options.DefaultTimeout.Should().Be(TimeSpan.FromSeconds(30));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Options_CanBeCustomized()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new RabbitMqTransportOptions
|
||||
{
|
||||
HostName = "rabbitmq.example.com",
|
||||
Port = 5673,
|
||||
VirtualHost = "/vhost",
|
||||
UserName = "admin",
|
||||
Password = "secret",
|
||||
UseSsl = true,
|
||||
SslCertPath = "/path/to/cert.pem",
|
||||
DurableQueues = true,
|
||||
AutoDeleteQueues = false,
|
||||
PrefetchCount = 50,
|
||||
ExchangePrefix = "myapp",
|
||||
QueuePrefix = "myqueues",
|
||||
NodeId = "node-1",
|
||||
InstanceId = "instance-1",
|
||||
AutomaticRecoveryEnabled = false,
|
||||
NetworkRecoveryInterval = TimeSpan.FromSeconds(10),
|
||||
DefaultTimeout = TimeSpan.FromMinutes(1)
|
||||
};
|
||||
|
||||
// Assert
|
||||
options.HostName.Should().Be("rabbitmq.example.com");
|
||||
options.Port.Should().Be(5673);
|
||||
options.VirtualHost.Should().Be("/vhost");
|
||||
options.UserName.Should().Be("admin");
|
||||
options.Password.Should().Be("secret");
|
||||
options.UseSsl.Should().BeTrue();
|
||||
options.SslCertPath.Should().Be("/path/to/cert.pem");
|
||||
options.DurableQueues.Should().BeTrue();
|
||||
options.AutoDeleteQueues.Should().BeFalse();
|
||||
options.PrefetchCount.Should().Be(50);
|
||||
options.ExchangePrefix.Should().Be("myapp");
|
||||
options.QueuePrefix.Should().Be("myqueues");
|
||||
options.NodeId.Should().Be("node-1");
|
||||
options.InstanceId.Should().Be("instance-1");
|
||||
options.AutomaticRecoveryEnabled.Should().BeFalse();
|
||||
options.NetworkRecoveryInterval.Should().Be(TimeSpan.FromSeconds(10));
|
||||
options.DefaultTimeout.Should().Be(TimeSpan.FromMinutes(1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<!-- Suppress CA2255 from OpenSSL auto-init shim included via Directory.Build.props -->
|
||||
<NoWarn>$(NoWarn);CA2255</NoWarn>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>StellaOps.Router.Transport.RabbitMq.Tests</RootNamespace>
|
||||
<!-- Disable Concelier test infrastructure (Mongo2Go, etc.) since not needed for Router tests -->
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Test SDK packages come from Directory.Build.props -->
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Router.Transport.RabbitMq\StellaOps.Router.Transport.RabbitMq.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Router.Testing\StellaOps.Router.Testing.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user