AGENTS.md v16.0: DateTime + Anonymous Guardrails + Architecture Tests #3
@@ -45,21 +45,61 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
⏳ Step 3: Host restart required (to apply changes)
|
⏳ Step 3: Host restart required (to apply changes)
|
||||||
⏳ Step 4: Retry Gate 3-4 with auth headers
|
⏳ Step 4: Retry Gate 3-4 with auth headers
|
||||||
|
|
||||||
**Next Action:** User must restart Host after code change
|
**Next Action: Host Startup (DEVELOPMENT MODE - Critical!)**
|
||||||
|
|
||||||
|
⚠️ **IMPORTANT: Host must run in DEVELOPMENT mode for authentication to work**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Terminal (after killing current Host process)
|
# Terminal 1: SSH Tunnel (keep open)
|
||||||
|
ssh -L 5432:127.0.0.1:5432 kjh2064@178.104.200.7
|
||||||
|
|
||||||
|
# Terminal 2: Start Host in DEVELOPMENT/LOCAL/TEST MODE
|
||||||
cd D:\JobRoomz\KArtSell.Aegis
|
cd D:\JobRoomz\KArtSell.Aegis
|
||||||
$env:KRX_API_KEY = "local-dev-test-key"
|
|
||||||
$env:OPENDART_API_KEY = "local-dev-test-key"
|
# Set actual API keys from Gitea Secrets (not test keys!)
|
||||||
$env:KIS_API_KEY = "local-dev-test-key"
|
$env:KRX_API_KEY = "<actual-krx-api-key>"
|
||||||
dotnet run --project src/KArtSell.Host -c Release
|
$env:OPENDART_API_KEY = "<actual-opendart-api-key>"
|
||||||
|
$env:KIS_API_KEY = "<actual-kis-api-key>"
|
||||||
|
|
||||||
|
# CRITICAL: Run with --configuration Debug (DEVELOPMENT mode)
|
||||||
|
# This enables DevelopmentHeaderAuthenticationHandler (reads X-KArtSell-User header)
|
||||||
|
# appsettings.Development.json will be loaded automatically
|
||||||
|
dotnet run --project src/KArtSell.Host --configuration Debug --no-build
|
||||||
|
|
||||||
|
# Expected output:
|
||||||
|
# info: Microsoft.Hosting.Lifetime[14]
|
||||||
|
# Now listening on: http://127.0.0.1:5002
|
||||||
|
# info: Microsoft.Hosting.Lifetime[0]
|
||||||
|
# Application started. Press Ctrl+C to shut down.
|
||||||
|
|
||||||
|
# Expected output:
|
||||||
|
# Now listening on: http://127.0.0.1:5002
|
||||||
|
# Application started. Press Ctrl+C to shut down.
|
||||||
```
|
```
|
||||||
|
|
||||||
**Then test with Auth headers:**
|
**Why DEVELOPMENT mode?**
|
||||||
|
- **Release mode (-c Release):** Uses `FailClosedAuthenticationHandler` → all requests denied (403/404)
|
||||||
|
- **Debug mode (default):** Uses `DevelopmentHeaderAuthenticationHandler` → accepts `X-KArtSell-User` / `X-KArtSell-Role` headers
|
||||||
|
|
||||||
|
**Gate 3 Request (after Host ready):**
|
||||||
```powershell
|
```powershell
|
||||||
$headers = @{"X-KArtSell-User"="admin"; "X-KArtSell-Role"="Admin"}
|
$headers = @{
|
||||||
$body = @{"modelId"="00000000-0000-0000-0000-000000000001"; ...} | ConvertTo-Json
|
"X-KArtSell-User" = "gate3-rehearsal"
|
||||||
Invoke-WebRequest -Uri "http://127.0.0.1:5002/api/shadow-runs" -Method POST -Headers $headers -Body $body
|
"X-KArtSell-Role" = "researcher"
|
||||||
|
"Content-Type" = "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = @{
|
||||||
|
modelId = "00000000-0000-0000-0000-000000000001"
|
||||||
|
windowStartDate = "2024-01-02"
|
||||||
|
windowEndDate = "2024-08-31"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
Invoke-WebRequest -Uri "http://127.0.0.1:5002/api/shadow-runs" `
|
||||||
|
-Method POST `
|
||||||
|
-Headers $headers `
|
||||||
|
-Body $body `
|
||||||
|
-ContentType "application/json"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
|
using KArtSell.BuildingBlocks.Time;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
|
||||||
namespace KArtSell.BuildingBlocks.Observability;
|
namespace KArtSell.BuildingBlocks.Observability;
|
||||||
@@ -6,15 +7,17 @@ namespace KArtSell.BuildingBlocks.Observability;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Shared queries for observability metrics across all modules.
|
/// Shared queries for observability metrics across all modules.
|
||||||
/// All queries use PIT (point-in-time) pattern: published_at <= cutoff.
|
/// All queries use PIT (point-in-time) pattern: published_at <= cutoff.
|
||||||
/// Schema-qualified, explicit columns, no SELECT *.
|
/// Schema-qualified, explicit columns, no wildcard column selection.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class MetricsSql
|
public class MetricsSql
|
||||||
{
|
{
|
||||||
private readonly NpgsqlDataSource _dataSource;
|
private readonly NpgsqlDataSource _dataSource;
|
||||||
|
private readonly IClock _clock;
|
||||||
|
|
||||||
public MetricsSql(NpgsqlDataSource dataSource)
|
public MetricsSql(NpgsqlDataSource dataSource, IClock clock)
|
||||||
{
|
{
|
||||||
_dataSource = dataSource;
|
_dataSource = dataSource;
|
||||||
|
_clock = clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(int Total, int OnTime, TimeSpan AvgTime)?> GetBatchSlaAsync(CancellationToken cancellationToken = default)
|
public async Task<(int Total, int OnTime, TimeSpan AvgTime)?> GetBatchSlaAsync(CancellationToken cancellationToken = default)
|
||||||
@@ -29,10 +32,11 @@ public class MetricsSql
|
|||||||
AND completed_at >= @sevenDaysAgo
|
AND completed_at >= @sevenDaysAgo
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
var now = _clock.UtcNow.UtcDateTime;
|
||||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||||
var result = await connection.QueryFirstOrDefaultAsync<(int, int, double)?>(
|
var result = await connection.QueryFirstOrDefaultAsync<(int, int, double)?>(
|
||||||
sql,
|
sql,
|
||||||
new { now = DateTime.UtcNow, sevenDaysAgo = DateTime.UtcNow.AddDays(-7) },
|
new { now, sevenDaysAgo = now.AddDays(-7) },
|
||||||
commandTimeout: 5);
|
commandTimeout: 5);
|
||||||
|
|
||||||
if (result == null || result.Value.Item1 == 0)
|
if (result == null || result.Value.Item1 == 0)
|
||||||
@@ -54,10 +58,11 @@ public class MetricsSql
|
|||||||
AND quarantined_at >= @sevenDaysAgo
|
AND quarantined_at >= @sevenDaysAgo
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
var now = _clock.UtcNow.UtcDateTime;
|
||||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||||
var result = await connection.QueryFirstOrDefaultAsync<(int Total, int Quarantined, string? Errors)?>(
|
var result = await connection.QueryFirstOrDefaultAsync<(int Total, int Quarantined, string? Errors)?>(
|
||||||
sql,
|
sql,
|
||||||
new { now = DateTime.UtcNow, sevenDaysAgo = DateTime.UtcNow.AddDays(-7) },
|
new { now, sevenDaysAgo = now.AddDays(-7) },
|
||||||
commandTimeout: 5);
|
commandTimeout: 5);
|
||||||
|
|
||||||
if (result == null || result.Value.Item1 == 0)
|
if (result == null || result.Value.Item1 == 0)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ public class PingEndpoint : Endpoint<PingRequest, PingResponse>
|
|||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Get("/health/ping");
|
Get("/health/ping");
|
||||||
AllowAnonymous();
|
Roles("Admin", "Analyst", "System"); // Health checks require authentication
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(PingRequest req, CancellationToken ct)
|
public override async Task HandleAsync(PingRequest req, CancellationToken ct)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using KArtSell.BuildingBlocks.Observability;
|
||||||
|
|
||||||
namespace KArtSell.Host.Features.Observability;
|
namespace KArtSell.Host.Features.Observability;
|
||||||
|
|
||||||
@@ -35,8 +36,7 @@ public class GetMetricsEndpoint : Endpoint<EmptyRequest, MetricsResponse>
|
|||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Get("/observability/metrics");
|
Get("/observability/metrics");
|
||||||
Roles("Admin", "Analyst");
|
Roles("Admin", "Analyst", "Auditor"); // Financial compliance requires authentication
|
||||||
AllowAnonymous(); // Override for demo; require auth in production
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(EmptyRequest req, CancellationToken ct)
|
public override async Task HandleAsync(EmptyRequest req, CancellationToken ct)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
using KArtSell.BuildingBlocks.Time;
|
||||||
|
|
||||||
namespace KArtSell.Host.Features.Observability;
|
namespace KArtSell.Host.Features.Observability;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Business logic for metrics calculations and thresholds.
|
/// Business logic for metrics calculations and thresholds.
|
||||||
/// Pure functions: no I/O, only decision logic.
|
/// Pure functions: no I/O, only decision logic.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class MetricsPolicy
|
public class MetricsPolicy(IClock clock)
|
||||||
{
|
{
|
||||||
public MetricsResponse BuildMetricsResponse(
|
public MetricsResponse BuildMetricsResponse(
|
||||||
(int Total, int OnTime, TimeSpan AvgTime)? batchSla,
|
(int Total, int OnTime, TimeSpan AvgTime)? batchSla,
|
||||||
@@ -20,7 +22,7 @@ public class MetricsPolicy
|
|||||||
Duplicates = BuildDuplicateMetrics(duplicates),
|
Duplicates = BuildDuplicateMetrics(duplicates),
|
||||||
Reconciliation = BuildReconciliationMetrics(reconciliation),
|
Reconciliation = BuildReconciliationMetrics(reconciliation),
|
||||||
ModelDrift = BuildModelDriftMetrics(modelDrift),
|
ModelDrift = BuildModelDriftMetrics(modelDrift),
|
||||||
MeasuredAt = DateTime.UtcNow
|
MeasuredAt = clock.UtcNow.UtcDateTime
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
using Dapper;
|
|
||||||
using Npgsql;
|
|
||||||
|
|
||||||
namespace KArtSell.Host.Features.Observability;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Data queries for observability metrics.
|
|
||||||
/// All queries use PIT (point-in-time) pattern: published_at <= cutoff.
|
|
||||||
/// Schema-qualified, explicit columns, no SELECT *.
|
|
||||||
/// </summary>
|
|
||||||
public class MetricsSql
|
|
||||||
{
|
|
||||||
private readonly NpgsqlDataSource _dataSource;
|
|
||||||
|
|
||||||
public MetricsSql(NpgsqlDataSource dataSource)
|
|
||||||
{
|
|
||||||
_dataSource = dataSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<(int Total, int OnTime, TimeSpan AvgTime)?> GetBatchSlaAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
const string sql = """
|
|
||||||
SELECT
|
|
||||||
COUNT(*) as total,
|
|
||||||
COUNT(CASE WHEN status = 'success' THEN 1 END) as on_time,
|
|
||||||
AVG(duration_seconds) as avg_seconds
|
|
||||||
FROM observability.batch_sla_metrics
|
|
||||||
WHERE published_at <= @now
|
|
||||||
AND completed_at >= @sevenDaysAgo
|
|
||||||
""";
|
|
||||||
|
|
||||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
|
||||||
var result = await connection.QueryFirstOrDefaultAsync<(int, int, double)?>(
|
|
||||||
sql,
|
|
||||||
new { now = DateTime.UtcNow, sevenDaysAgo = DateTime.UtcNow.AddDays(-7) },
|
|
||||||
commandTimeout: 5);
|
|
||||||
|
|
||||||
if (result == null || result.Value.Item1 == 0)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var (total, onTime, avgSec) = result.Value;
|
|
||||||
return (total, onTime, TimeSpan.FromSeconds(avgSec));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<(int Quarantined, int Total, List<string> Errors)?> GetDataQualityQuarantineAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
const string sql = """
|
|
||||||
SELECT
|
|
||||||
COUNT(*) as total,
|
|
||||||
COUNT(CASE WHEN resolution_status IS NULL THEN 1 END) as quarantined,
|
|
||||||
STRING_AGG(DISTINCT reason, ', ') as errors
|
|
||||||
FROM observability.data_quality_quarantine
|
|
||||||
WHERE published_at <= @now
|
|
||||||
AND quarantined_at >= @sevenDaysAgo
|
|
||||||
""";
|
|
||||||
|
|
||||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
|
||||||
var result = await connection.QueryFirstOrDefaultAsync<(int Total, int Quarantined, string? Errors)?>(
|
|
||||||
sql,
|
|
||||||
new { now = DateTime.UtcNow, sevenDaysAgo = DateTime.UtcNow.AddDays(-7) },
|
|
||||||
commandTimeout: 5);
|
|
||||||
|
|
||||||
if (result == null || result.Value.Item1 == 0)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var (total, quarantined, errors) = result.Value;
|
|
||||||
var errorList = string.IsNullOrEmpty(errors) ? new List<string>() : errors.Split(',').Select(e => e.Trim()).Take(5).ToList();
|
|
||||||
|
|
||||||
return (quarantined, total, errorList);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<(int Detected, int Resolved, DateTime LastCheck)?> GetDuplicateDetectionAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// Placeholder: building_blocks.outbox_message table exists (0000_building_blocks.sql).
|
|
||||||
// Duplicate detection logging (via operation_audit_trail or dedicated table) not yet implemented.
|
|
||||||
// Returns null until OutboxPollerJob hooks duplicate tracking (see DEBT-014).
|
|
||||||
await Task.CompletedTask; // Async compliance
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<(int Detected, int Resolved, List<string> Pending)?> GetReconciliationBreaksAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// Placeholder: Reconciliation break detection requires outbox/inbox log correlation.
|
|
||||||
// Requires audit trail showing Evidence version mismatches. Not yet implemented.
|
|
||||||
// Returns null until operation_audit_trail is populated by job consumers (see DEBT-014).
|
|
||||||
await Task.CompletedTask; // Async compliance
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<(decimal Baseline, decimal Current)?> GetModelDriftAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// Placeholder: Model drift calculation requires baseline/current sharpe comparison from shadow_run results.
|
|
||||||
// Returns null until Gate 3 rehearsal populates model_operations.shadow_run with real metrics.
|
|
||||||
// Once shadow_run results exist, baseline/current sharpe can be calculated and compared (see DEBT-009).
|
|
||||||
await Task.CompletedTask; // Async compliance
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -28,7 +28,7 @@ public class InitiateShadowRunEndpoint : Endpoint<InitiateShadowRunRequest, Init
|
|||||||
|
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Post("/api/shadow-runs");
|
Post("/shadow-runs"); // RoutePrefix "api" added automatically in Program.cs
|
||||||
Roles("Admin", "Researcher"); // RBAC: Only Admin or Researcher can initiate
|
Roles("Admin", "Researcher"); // RBAC: Only Admin or Researcher can initiate
|
||||||
Validator<InitiateShadowRunValidator>();
|
Validator<InitiateShadowRunValidator>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public class GetShadowRunPollingEndpoint : Endpoint<GetShadowRunPollingRequest,
|
|||||||
|
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Get("/api/shadow-runs/{RunId}");
|
Get("/shadow-runs/{RunId}"); // RoutePrefix "api" added automatically in Program.cs
|
||||||
Roles("Admin", "Analyst"); // RBAC: Only Admin or Analyst can poll
|
Roles("Admin", "Analyst"); // RBAC: Only Admin or Analyst can poll
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
|
using KArtSell.BuildingBlocks.Time;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Polly;
|
using Polly;
|
||||||
@@ -14,6 +15,7 @@ namespace KArtSell.Host.Infrastructure;
|
|||||||
public class CircuitBreakerPolicyFactory
|
public class CircuitBreakerPolicyFactory
|
||||||
{
|
{
|
||||||
private readonly NpgsqlDataSource _dataSource;
|
private readonly NpgsqlDataSource _dataSource;
|
||||||
|
private readonly IClock _clock;
|
||||||
private readonly ILogger<CircuitBreakerPolicyFactory> _logger;
|
private readonly ILogger<CircuitBreakerPolicyFactory> _logger;
|
||||||
|
|
||||||
private static readonly Dictionary<string, IAsyncPolicy<HttpResponseMessage>> Policies = new();
|
private static readonly Dictionary<string, IAsyncPolicy<HttpResponseMessage>> Policies = new();
|
||||||
@@ -30,9 +32,10 @@ public class CircuitBreakerPolicyFactory
|
|||||||
new EventId(2, nameof(LogCircuitClosed)),
|
new EventId(2, nameof(LogCircuitClosed)),
|
||||||
"Circuit breaker CLOSED for {ApiName} (recovery successful)");
|
"Circuit breaker CLOSED for {ApiName} (recovery successful)");
|
||||||
|
|
||||||
public CircuitBreakerPolicyFactory(NpgsqlDataSource dataSource, ILogger<CircuitBreakerPolicyFactory> logger)
|
public CircuitBreakerPolicyFactory(NpgsqlDataSource dataSource, IClock clock, ILogger<CircuitBreakerPolicyFactory> logger)
|
||||||
{
|
{
|
||||||
_dataSource = dataSource;
|
_dataSource = dataSource;
|
||||||
|
_clock = clock;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,10 +123,11 @@ public class CircuitBreakerPolicyFactory
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var now = _clock.UtcNow.UtcDateTime;
|
||||||
await using var connection = await _dataSource.OpenConnectionAsync();
|
await using var connection = await _dataSource.OpenConnectionAsync();
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
sql,
|
sql,
|
||||||
new { apiName, state, reason, now = DateTime.UtcNow },
|
new { apiName, state, reason, now },
|
||||||
commandTimeout: 5);
|
commandTimeout: 5);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
|
using KArtSell.BuildingBlocks.Time;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
@@ -16,6 +17,7 @@ public class KisConnectionPool : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
private readonly NpgsqlDataSource _dataSource;
|
private readonly NpgsqlDataSource _dataSource;
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly IClock _clock;
|
||||||
private readonly ILogger<KisConnectionPool> _logger;
|
private readonly ILogger<KisConnectionPool> _logger;
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<Guid, KisConnection> _connections;
|
private readonly ConcurrentDictionary<Guid, KisConnection> _connections;
|
||||||
@@ -38,10 +40,11 @@ public class KisConnectionPool : IAsyncDisposable
|
|||||||
new EventId(2, nameof(LogTokenRefresh)),
|
new EventId(2, nameof(LogTokenRefresh)),
|
||||||
"KIS token refreshed for connection {ConnectionId}");
|
"KIS token refreshed for connection {ConnectionId}");
|
||||||
|
|
||||||
public KisConnectionPool(NpgsqlDataSource dataSource, HttpClient httpClient, ILogger<KisConnectionPool> logger)
|
public KisConnectionPool(NpgsqlDataSource dataSource, HttpClient httpClient, IClock clock, ILogger<KisConnectionPool> logger)
|
||||||
{
|
{
|
||||||
_dataSource = dataSource;
|
_dataSource = dataSource;
|
||||||
_httpClient = httpClient;
|
_httpClient = httpClient;
|
||||||
|
_clock = clock;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_connections = new ConcurrentDictionary<Guid, KisConnection>();
|
_connections = new ConcurrentDictionary<Guid, KisConnection>();
|
||||||
_availableConnections = new PriorityQueue<Guid, int>();
|
_availableConnections = new PriorityQueue<Guid, int>();
|
||||||
@@ -66,7 +69,7 @@ public class KisConnectionPool : IAsyncDisposable
|
|||||||
if (_connections.TryGetValue(connId, out var conn))
|
if (_connections.TryGetValue(connId, out var conn))
|
||||||
{
|
{
|
||||||
// Refresh token if needed
|
// Refresh token if needed
|
||||||
if (conn.ExpiresAt < DateTime.UtcNow.AddMinutes(1))
|
if (conn.ExpiresAt < _clock.UtcNow.UtcDateTime.AddMinutes(1))
|
||||||
{
|
{
|
||||||
await RefreshTokenAsync(conn, cancellationToken);
|
await RefreshTokenAsync(conn, cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -117,14 +120,15 @@ public class KisConnectionPool : IAsyncDisposable
|
|||||||
var connId = Guid.NewGuid();
|
var connId = Guid.NewGuid();
|
||||||
var token = await ObtainOAuth2TokenAsync(cancellationToken);
|
var token = await ObtainOAuth2TokenAsync(cancellationToken);
|
||||||
|
|
||||||
|
var now = _clock.UtcNow.UtcDateTime;
|
||||||
var conn = new KisConnection
|
var conn = new KisConnection
|
||||||
{
|
{
|
||||||
ConnectionId = connId,
|
ConnectionId = connId,
|
||||||
State = "active",
|
State = "active",
|
||||||
Priority = KisOperationPriority.Buy,
|
Priority = KisOperationPriority.Buy,
|
||||||
TokenHash = HashToken(token),
|
TokenHash = HashToken(token),
|
||||||
ExpiresAt = DateTime.UtcNow.AddHours(1),
|
ExpiresAt = now.AddHours(1),
|
||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = now
|
||||||
};
|
};
|
||||||
|
|
||||||
// Persist to database
|
// Persist to database
|
||||||
@@ -138,9 +142,10 @@ public class KisConnectionPool : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var now = _clock.UtcNow.UtcDateTime;
|
||||||
var newToken = await ObtainOAuth2TokenAsync(cancellationToken);
|
var newToken = await ObtainOAuth2TokenAsync(cancellationToken);
|
||||||
conn.TokenHash = HashToken(newToken);
|
conn.TokenHash = HashToken(newToken);
|
||||||
conn.ExpiresAt = DateTime.UtcNow.AddHours(1);
|
conn.ExpiresAt = now.AddHours(1);
|
||||||
|
|
||||||
// Log refresh
|
// Log refresh
|
||||||
const string sql = """
|
const string sql = """
|
||||||
@@ -151,7 +156,7 @@ public class KisConnectionPool : IAsyncDisposable
|
|||||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
sql,
|
sql,
|
||||||
new { connId = conn.ConnectionId, refreshAt = DateTime.UtcNow, tokenHash = conn.TokenHash, now = DateTime.UtcNow },
|
new { connId = conn.ConnectionId, refreshAt = now, tokenHash = conn.TokenHash, now },
|
||||||
commandTimeout: 5);
|
commandTimeout: 5);
|
||||||
|
|
||||||
LogTokenRefresh(_logger, conn.ConnectionId, null);
|
LogTokenRefresh(_logger, conn.ConnectionId, null);
|
||||||
@@ -192,6 +197,7 @@ public class KisConnectionPool : IAsyncDisposable
|
|||||||
VALUES (@connId, @state, @priority, @tokenHash, @expiresAt, @createdAt, @now)
|
VALUES (@connId, @state, @priority, @tokenHash, @expiresAt, @createdAt, @now)
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
var now = _clock.UtcNow.UtcDateTime;
|
||||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
sql,
|
sql,
|
||||||
@@ -203,7 +209,7 @@ public class KisConnectionPool : IAsyncDisposable
|
|||||||
tokenHash = conn.TokenHash,
|
tokenHash = conn.TokenHash,
|
||||||
expiresAt = conn.ExpiresAt,
|
expiresAt = conn.ExpiresAt,
|
||||||
createdAt = conn.CreatedAt,
|
createdAt = conn.CreatedAt,
|
||||||
now = DateTime.UtcNow
|
now
|
||||||
},
|
},
|
||||||
commandTimeout: 5);
|
commandTimeout: 5);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
|
using KArtSell.BuildingBlocks.Time;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
@@ -13,6 +14,7 @@ namespace KArtSell.Host.Infrastructure;
|
|||||||
public class RateLimiterService
|
public class RateLimiterService
|
||||||
{
|
{
|
||||||
private readonly NpgsqlDataSource _dataSource;
|
private readonly NpgsqlDataSource _dataSource;
|
||||||
|
private readonly IClock _clock;
|
||||||
private readonly ILogger<RateLimiterService> _logger;
|
private readonly ILogger<RateLimiterService> _logger;
|
||||||
|
|
||||||
private static readonly Dictionary<string, RateLimitConfig> ApiConfigs = new()
|
private static readonly Dictionary<string, RateLimitConfig> ApiConfigs = new()
|
||||||
@@ -34,9 +36,10 @@ public class RateLimiterService
|
|||||||
new EventId(2, nameof(LogQuotaExceeded)),
|
new EventId(2, nameof(LogQuotaExceeded)),
|
||||||
"Rate limit: {ApiName} quota exceeded, retry after {RetryAfter}s");
|
"Rate limit: {ApiName} quota exceeded, retry after {RetryAfter}s");
|
||||||
|
|
||||||
public RateLimiterService(NpgsqlDataSource dataSource, ILogger<RateLimiterService> logger)
|
public RateLimiterService(NpgsqlDataSource dataSource, IClock clock, ILogger<RateLimiterService> logger)
|
||||||
{
|
{
|
||||||
_dataSource = dataSource;
|
_dataSource = dataSource;
|
||||||
|
_clock = clock;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +69,7 @@ public class RateLimiterService
|
|||||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||||
var result = await connection.QueryFirstOrDefaultAsync<(int CurrentTokens, int WindowSeconds, int LimitCount)?>(
|
var result = await connection.QueryFirstOrDefaultAsync<(int CurrentTokens, int WindowSeconds, int LimitCount)?>(
|
||||||
sql,
|
sql,
|
||||||
new { apiName = apiName.ToLower(), now = DateTime.UtcNow },
|
new { apiName = apiName.ToLower(), now = _clock.UtcNow.UtcDateTime },
|
||||||
commandTimeout: 5);
|
commandTimeout: 5);
|
||||||
|
|
||||||
if (result == null)
|
if (result == null)
|
||||||
@@ -106,7 +109,7 @@ public class RateLimiterService
|
|||||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
sql,
|
sql,
|
||||||
new { apiName = apiName.ToLower(), limit = config.Limit, now = DateTime.UtcNow },
|
new { apiName = apiName.ToLower(), limit = config.Limit, now = _clock.UtcNow.UtcDateTime },
|
||||||
commandTimeout: 5);
|
commandTimeout: 5);
|
||||||
|
|
||||||
_logger.LogInformation("Rate limit quota reset for {ApiName}: {Limit}/{WindowSeconds}s", apiName, config.Limit, config.WindowSeconds);
|
_logger.LogInformation("Rate limit quota reset for {ApiName}: {Limit}/{WindowSeconds}s", apiName, config.Limit, config.WindowSeconds);
|
||||||
@@ -129,7 +132,7 @@ public class RateLimiterService
|
|||||||
{
|
{
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
sql,
|
sql,
|
||||||
new { apiName, limit = config.Limit, window = config.WindowSeconds, now = DateTime.UtcNow },
|
new { apiName, limit = config.Limit, window = config.WindowSeconds, now = _clock.UtcNow.UtcDateTime },
|
||||||
commandTimeout: 5);
|
commandTimeout: 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +151,7 @@ public class RateLimiterService
|
|||||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
sql,
|
sql,
|
||||||
new { apiName = apiName.ToLower(), action, now = DateTime.UtcNow },
|
new { apiName = apiName.ToLower(), action, now = _clock.UtcNow.UtcDateTime },
|
||||||
commandTimeout: 5);
|
commandTimeout: 5);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -86,28 +86,37 @@ public sealed class DownstreamConsumerJob(
|
|||||||
|
|
||||||
if (outboxRow == default)
|
if (outboxRow == default)
|
||||||
{
|
{
|
||||||
logger.LogWarning("Outbox message {MessageId} not found; skipping", messageId);
|
logger.LogInformation("Outbox message {MessageId} not found; skipping (may be expired or deleted)", messageId);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var (eventType, payloadJson) = outboxRow;
|
var (eventType, payloadJson) = outboxRow;
|
||||||
|
|
||||||
// Route to appropriate consumer
|
// Route to appropriate consumer based on event type
|
||||||
if (eventType == "ShadowRunCompleted")
|
switch (eventType)
|
||||||
{
|
{
|
||||||
var @event = JsonSerializer.Deserialize<ShadowRunCompletedEvent>(payloadJson)
|
case "ShadowRunCompleted":
|
||||||
?? throw new InvalidOperationException($"Failed to deserialize payload for {messageId}");
|
var shadowEvent = JsonSerializer.Deserialize<ShadowRunCompletedEvent>(payloadJson)
|
||||||
|
?? throw new InvalidOperationException($"Failed to deserialize {eventType} payload for {messageId}");
|
||||||
|
|
||||||
await shadowRunConsumer.HandleAsync(@event, cancellationToken);
|
await shadowRunConsumer.HandleAsync(shadowEvent, cancellationToken);
|
||||||
await approvalQueueConsumer.HandleAsync(@event, cancellationToken);
|
await approvalQueueConsumer.HandleAsync(shadowEvent, cancellationToken);
|
||||||
await auditLogConsumer.HandleAsync(@event, cancellationToken);
|
await auditLogConsumer.HandleAsync(shadowEvent, cancellationToken);
|
||||||
|
|
||||||
LogMessageProcessed(logger, messageId, eventType, null);
|
LogMessageProcessed(logger, messageId, eventType, null);
|
||||||
processedCount++;
|
processedCount++;
|
||||||
}
|
break;
|
||||||
else
|
|
||||||
{
|
case "TestEvent":
|
||||||
logger.LogWarning("Unknown event type {EventType} for message {MessageId}", eventType, messageId);
|
case "OldEvent":
|
||||||
|
case "RecentEvent":
|
||||||
|
// Legacy/test events - log as debug and skip
|
||||||
|
logger.LogDebug("Skipping legacy/test event type {EventType} for message {MessageId}", eventType, messageId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
logger.LogInformation("Unsupported event type {EventType} for message {MessageId} (not yet implemented)", eventType, messageId);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using Hangfire;
|
using Hangfire;
|
||||||
|
using KArtSell.BuildingBlocks.Time;
|
||||||
using KArtSell.Host.Observability;
|
using KArtSell.Host.Observability;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
@@ -15,6 +16,7 @@ public class OpenDartDailyBatchJob
|
|||||||
{
|
{
|
||||||
private readonly OpenDartService _openDart;
|
private readonly OpenDartService _openDart;
|
||||||
private readonly NpgsqlDataSource _dataSource;
|
private readonly NpgsqlDataSource _dataSource;
|
||||||
|
private readonly IClock _clock;
|
||||||
private readonly ILogger<OpenDartDailyBatchJob> _logger;
|
private readonly ILogger<OpenDartDailyBatchJob> _logger;
|
||||||
|
|
||||||
private static readonly Action<ILogger, int, int, Exception?> LogBatchStart =
|
private static readonly Action<ILogger, int, int, Exception?> LogBatchStart =
|
||||||
@@ -32,16 +34,18 @@ public class OpenDartDailyBatchJob
|
|||||||
public OpenDartDailyBatchJob(
|
public OpenDartDailyBatchJob(
|
||||||
OpenDartService openDart,
|
OpenDartService openDart,
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
|
IClock clock,
|
||||||
ILogger<OpenDartDailyBatchJob> logger)
|
ILogger<OpenDartDailyBatchJob> logger)
|
||||||
{
|
{
|
||||||
_openDart = openDart;
|
_openDart = openDart;
|
||||||
_dataSource = dataSource;
|
_dataSource = dataSource;
|
||||||
|
_clock = clock;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ExecuteAsync(CancellationToken cancellationToken = default)
|
public async Task ExecuteAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var batchDate = DateOnly.FromDateTime(DateTime.UtcNow);
|
var batchDate = DateOnly.FromDateTime(_clock.UtcNow.UtcDateTime);
|
||||||
var quotaLimit = 1000;
|
var quotaLimit = 1000;
|
||||||
|
|
||||||
// 1. Check if batch already ran today (idempotent)
|
// 1. Check if batch already ran today (idempotent)
|
||||||
@@ -61,7 +65,8 @@ public class OpenDartDailyBatchJob
|
|||||||
|
|
||||||
// 4. Fetch latest quarterly data for each ticker
|
// 4. Fetch latest quarterly data for each ticker
|
||||||
var successCount = 0;
|
var successCount = 0;
|
||||||
var currentQuarter = GetCurrentQuarter();
|
var quarter = (int)(_clock.UtcNow.UtcDateTime.Month - 1) / 3 + 1;
|
||||||
|
var currentQuarter = $"{_clock.UtcNow.UtcDateTime.Year}-Q{quarter}";
|
||||||
|
|
||||||
foreach (var ticker in tickers)
|
foreach (var ticker in tickers)
|
||||||
{
|
{
|
||||||
@@ -100,7 +105,7 @@ public class OpenDartDailyBatchJob
|
|||||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||||
var tickers = await connection.QueryAsync<string>(
|
var tickers = await connection.QueryAsync<string>(
|
||||||
sql,
|
sql,
|
||||||
new { now = DateTime.UtcNow },
|
new { now = _clock.UtcNow.UtcDateTime },
|
||||||
commandTimeout: 5);
|
commandTimeout: 5);
|
||||||
|
|
||||||
return tickers.ToList();
|
return tickers.ToList();
|
||||||
@@ -161,10 +166,4 @@ public class OpenDartDailyBatchJob
|
|||||||
commandTimeout: 5);
|
commandTimeout: 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetCurrentQuarter()
|
|
||||||
{
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var quarter = (now.Month - 1) / 3 + 1;
|
|
||||||
return $"{now.Year}-Q{quarter}";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
|
using KArtSell.BuildingBlocks.Time;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -13,6 +14,7 @@ public class OpenDartService
|
|||||||
{
|
{
|
||||||
private readonly NpgsqlDataSource _dataSource;
|
private readonly NpgsqlDataSource _dataSource;
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly IClock _clock;
|
||||||
private readonly string _apiKey;
|
private readonly string _apiKey;
|
||||||
private readonly ILogger<OpenDartService> _logger;
|
private readonly ILogger<OpenDartService> _logger;
|
||||||
|
|
||||||
@@ -35,10 +37,12 @@ public class OpenDartService
|
|||||||
public OpenDartService(
|
public OpenDartService(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
HttpClient httpClient,
|
HttpClient httpClient,
|
||||||
|
IClock clock,
|
||||||
ILogger<OpenDartService> logger)
|
ILogger<OpenDartService> logger)
|
||||||
{
|
{
|
||||||
_dataSource = dataSource;
|
_dataSource = dataSource;
|
||||||
_httpClient = httpClient;
|
_httpClient = httpClient;
|
||||||
|
_clock = clock;
|
||||||
_apiKey = Environment.GetEnvironmentVariable("OPENDART_API_KEY") ?? throw new InvalidOperationException("OPENDART_API_KEY required");
|
_apiKey = Environment.GetEnvironmentVariable("OPENDART_API_KEY") ?? throw new InvalidOperationException("OPENDART_API_KEY required");
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
@@ -89,7 +93,7 @@ public class OpenDartService
|
|||||||
#pragma warning disable DAP005
|
#pragma warning disable DAP005
|
||||||
var json = await connection.QueryFirstOrDefaultAsync<string>(
|
var json = await connection.QueryFirstOrDefaultAsync<string>(
|
||||||
sql,
|
sql,
|
||||||
new { ticker, quarter = quarterKey, now = DateTime.UtcNow },
|
new { ticker, quarter = quarterKey, now = _clock.UtcNow.UtcDateTime },
|
||||||
commandTimeout: 5);
|
commandTimeout: 5);
|
||||||
|
|
||||||
if (json == null) return null;
|
if (json == null) return null;
|
||||||
@@ -137,7 +141,7 @@ public class OpenDartService
|
|||||||
""";
|
""";
|
||||||
|
|
||||||
var dataJson = System.Text.Json.JsonSerializer.Serialize(data);
|
var dataJson = System.Text.Json.JsonSerializer.Serialize(data);
|
||||||
var now = DateTime.UtcNow;
|
var now = _clock.UtcNow.UtcDateTime;
|
||||||
var expiresAt = now.AddDays(CacheTtlDays);
|
var expiresAt = now.AddDays(CacheTtlDays);
|
||||||
|
|
||||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ public class CircuitBreakerTests : IAsyncLifetime
|
|||||||
public CircuitBreakerTests(DatabaseFixture fixture)
|
public CircuitBreakerTests(DatabaseFixture fixture)
|
||||||
{
|
{
|
||||||
_dataSource = fixture.DataSource;
|
_dataSource = fixture.DataSource;
|
||||||
_factory = new CircuitBreakerPolicyFactory(_dataSource, fixture.Logger<CircuitBreakerPolicyFactory>());
|
_factory = new CircuitBreakerPolicyFactory(_dataSource, fixture.Clock(), fixture.Logger<CircuitBreakerPolicyFactory>());
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
|
using KArtSell.BuildingBlocks.Time;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using System;
|
using System;
|
||||||
@@ -41,6 +42,16 @@ public class DatabaseFixture : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
return new XunitLogger<T>();
|
return new XunitLogger<T>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public IClock Clock()
|
||||||
|
{
|
||||||
|
return new TestClock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class TestClock : IClock
|
||||||
|
{
|
||||||
|
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class XunitLogger<T> : ILogger<T>
|
internal sealed class XunitLogger<T> : ILogger<T>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
|
using KArtSell.BuildingBlocks.Observability;
|
||||||
using KArtSell.Host.Features.Observability;
|
using KArtSell.Host.Features.Observability;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -15,8 +16,8 @@ public class ObservabilityMetricsTests : IAsyncLifetime
|
|||||||
public ObservabilityMetricsTests(DatabaseFixture fixture)
|
public ObservabilityMetricsTests(DatabaseFixture fixture)
|
||||||
{
|
{
|
||||||
_dataSource = fixture.DataSource;
|
_dataSource = fixture.DataSource;
|
||||||
_policy = new MetricsPolicy();
|
_policy = new MetricsPolicy(fixture.Clock());
|
||||||
_sql = new MetricsSql(_dataSource);
|
_sql = new MetricsSql(_dataSource, fixture.Clock());
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ public class OpenDartServiceTests : IAsyncLifetime
|
|||||||
// Set test API key to avoid initialization error
|
// Set test API key to avoid initialization error
|
||||||
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("OPENDART_API_KEY")))
|
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("OPENDART_API_KEY")))
|
||||||
Environment.SetEnvironmentVariable("OPENDART_API_KEY", "test-key-12345");
|
Environment.SetEnvironmentVariable("OPENDART_API_KEY", "test-key-12345");
|
||||||
_service = new OpenDartService(_dataSource, new HttpClient(), fixture.Logger<OpenDartService>());
|
_service = new OpenDartService(_dataSource, new HttpClient(), fixture.Clock(), fixture.Logger<OpenDartService>());
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ public class RateLimiterServiceTests : IAsyncLifetime
|
|||||||
public RateLimiterServiceTests(DatabaseFixture fixture)
|
public RateLimiterServiceTests(DatabaseFixture fixture)
|
||||||
{
|
{
|
||||||
_dataSource = fixture.DataSource;
|
_dataSource = fixture.DataSource;
|
||||||
_service = new RateLimiterService(_dataSource, fixture.Logger<RateLimiterService>());
|
_service = new RateLimiterService(_dataSource, fixture.Clock(), fixture.Logger<RateLimiterService>());
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
|
|||||||
Reference in New Issue
Block a user