diff --git a/src/KArtSell.BuildingBlocks/Observability/MetricsSql.cs b/src/KArtSell.BuildingBlocks/Observability/MetricsSql.cs index 49c5fa0b..7a6faaef 100644 --- a/src/KArtSell.BuildingBlocks/Observability/MetricsSql.cs +++ b/src/KArtSell.BuildingBlocks/Observability/MetricsSql.cs @@ -1,4 +1,5 @@ using Dapper; +using KArtSell.BuildingBlocks.Time; using Npgsql; namespace KArtSell.BuildingBlocks.Observability; @@ -6,15 +7,17 @@ namespace KArtSell.BuildingBlocks.Observability; /// /// Shared queries for observability metrics across all modules. /// 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. /// public class MetricsSql { private readonly NpgsqlDataSource _dataSource; + private readonly IClock _clock; - public MetricsSql(NpgsqlDataSource dataSource) + public MetricsSql(NpgsqlDataSource dataSource, IClock clock) { _dataSource = dataSource; + _clock = clock; } public async Task<(int Total, int OnTime, TimeSpan AvgTime)?> GetBatchSlaAsync(CancellationToken cancellationToken = default) @@ -29,10 +32,11 @@ public class MetricsSql AND completed_at >= @sevenDaysAgo """; + var now = _clock.UtcNow.UtcDateTime; 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) }, + new { now, sevenDaysAgo = now.AddDays(-7) }, commandTimeout: 5); if (result == null || result.Value.Item1 == 0) @@ -54,10 +58,11 @@ public class MetricsSql AND quarantined_at >= @sevenDaysAgo """; + var now = _clock.UtcNow.UtcDateTime; 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) }, + new { now, sevenDaysAgo = now.AddDays(-7) }, commandTimeout: 5); if (result == null || result.Value.Item1 == 0) diff --git a/src/KArtSell.Host/Features/Observability/MetricsPolicy.cs b/src/KArtSell.Host/Features/Observability/MetricsPolicy.cs index d3df623e..bb49bf2f 100644 --- a/src/KArtSell.Host/Features/Observability/MetricsPolicy.cs +++ b/src/KArtSell.Host/Features/Observability/MetricsPolicy.cs @@ -1,10 +1,12 @@ +using KArtSell.BuildingBlocks.Time; + namespace KArtSell.Host.Features.Observability; /// /// Business logic for metrics calculations and thresholds. /// Pure functions: no I/O, only decision logic. /// -public class MetricsPolicy +public class MetricsPolicy(IClock clock) { public MetricsResponse BuildMetricsResponse( (int Total, int OnTime, TimeSpan AvgTime)? batchSla, @@ -20,7 +22,7 @@ public class MetricsPolicy Duplicates = BuildDuplicateMetrics(duplicates), Reconciliation = BuildReconciliationMetrics(reconciliation), ModelDrift = BuildModelDriftMetrics(modelDrift), - MeasuredAt = DateTime.UtcNow + MeasuredAt = clock.UtcNow.UtcDateTime }; } diff --git a/src/KArtSell.Host/Infrastructure/CircuitBreakerPolicy.cs b/src/KArtSell.Host/Infrastructure/CircuitBreakerPolicy.cs index ea565fae..dd8c464d 100644 --- a/src/KArtSell.Host/Infrastructure/CircuitBreakerPolicy.cs +++ b/src/KArtSell.Host/Infrastructure/CircuitBreakerPolicy.cs @@ -1,4 +1,5 @@ using Dapper; +using KArtSell.BuildingBlocks.Time; using Microsoft.Extensions.Logging; using Npgsql; using Polly; @@ -14,6 +15,7 @@ namespace KArtSell.Host.Infrastructure; public class CircuitBreakerPolicyFactory { private readonly NpgsqlDataSource _dataSource; + private readonly IClock _clock; private readonly ILogger _logger; private static readonly Dictionary> Policies = new(); @@ -30,9 +32,10 @@ public class CircuitBreakerPolicyFactory new EventId(2, nameof(LogCircuitClosed)), "Circuit breaker CLOSED for {ApiName} (recovery successful)"); - public CircuitBreakerPolicyFactory(NpgsqlDataSource dataSource, ILogger logger) + public CircuitBreakerPolicyFactory(NpgsqlDataSource dataSource, IClock clock, ILogger logger) { _dataSource = dataSource; + _clock = clock; _logger = logger; } @@ -120,10 +123,11 @@ public class CircuitBreakerPolicyFactory try { + var now = _clock.UtcNow.UtcDateTime; await using var connection = await _dataSource.OpenConnectionAsync(); await connection.ExecuteAsync( sql, - new { apiName, state, reason, now = DateTime.UtcNow }, + new { apiName, state, reason, now }, commandTimeout: 5); } catch (Exception ex) diff --git a/src/KArtSell.Host/Infrastructure/KisConnectionPool.cs b/src/KArtSell.Host/Infrastructure/KisConnectionPool.cs index 936e510a..cdf16b09 100644 --- a/src/KArtSell.Host/Infrastructure/KisConnectionPool.cs +++ b/src/KArtSell.Host/Infrastructure/KisConnectionPool.cs @@ -1,4 +1,5 @@ using Dapper; +using KArtSell.BuildingBlocks.Time; using Microsoft.Extensions.Logging; using Npgsql; using System.Collections.Concurrent; @@ -16,6 +17,7 @@ public class KisConnectionPool : IAsyncDisposable { private readonly NpgsqlDataSource _dataSource; private readonly HttpClient _httpClient; + private readonly IClock _clock; private readonly ILogger _logger; private readonly ConcurrentDictionary _connections; @@ -38,10 +40,11 @@ public class KisConnectionPool : IAsyncDisposable new EventId(2, nameof(LogTokenRefresh)), "KIS token refreshed for connection {ConnectionId}"); - public KisConnectionPool(NpgsqlDataSource dataSource, HttpClient httpClient, ILogger logger) + public KisConnectionPool(NpgsqlDataSource dataSource, HttpClient httpClient, IClock clock, ILogger logger) { _dataSource = dataSource; _httpClient = httpClient; + _clock = clock; _logger = logger; _connections = new ConcurrentDictionary(); _availableConnections = new PriorityQueue(); @@ -66,7 +69,7 @@ public class KisConnectionPool : IAsyncDisposable if (_connections.TryGetValue(connId, out var conn)) { // Refresh token if needed - if (conn.ExpiresAt < DateTime.UtcNow.AddMinutes(1)) + if (conn.ExpiresAt < _clock.UtcNow.UtcDateTime.AddMinutes(1)) { await RefreshTokenAsync(conn, cancellationToken); } @@ -117,14 +120,15 @@ public class KisConnectionPool : IAsyncDisposable var connId = Guid.NewGuid(); var token = await ObtainOAuth2TokenAsync(cancellationToken); + var now = _clock.UtcNow.UtcDateTime; var conn = new KisConnection { ConnectionId = connId, State = "active", Priority = KisOperationPriority.Buy, TokenHash = HashToken(token), - ExpiresAt = DateTime.UtcNow.AddHours(1), - CreatedAt = DateTime.UtcNow + ExpiresAt = now.AddHours(1), + CreatedAt = now }; // Persist to database @@ -138,9 +142,10 @@ public class KisConnectionPool : IAsyncDisposable { try { + var now = _clock.UtcNow.UtcDateTime; var newToken = await ObtainOAuth2TokenAsync(cancellationToken); conn.TokenHash = HashToken(newToken); - conn.ExpiresAt = DateTime.UtcNow.AddHours(1); + conn.ExpiresAt = now.AddHours(1); // Log refresh const string sql = """ @@ -151,7 +156,7 @@ public class KisConnectionPool : IAsyncDisposable await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); await connection.ExecuteAsync( 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); LogTokenRefresh(_logger, conn.ConnectionId, null); @@ -192,6 +197,7 @@ public class KisConnectionPool : IAsyncDisposable VALUES (@connId, @state, @priority, @tokenHash, @expiresAt, @createdAt, @now) """; + var now = _clock.UtcNow.UtcDateTime; await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); await connection.ExecuteAsync( sql, @@ -203,7 +209,7 @@ public class KisConnectionPool : IAsyncDisposable tokenHash = conn.TokenHash, expiresAt = conn.ExpiresAt, createdAt = conn.CreatedAt, - now = DateTime.UtcNow + now }, commandTimeout: 5); } diff --git a/src/KArtSell.Host/Infrastructure/RateLimiterService.cs b/src/KArtSell.Host/Infrastructure/RateLimiterService.cs index 41002f16..ac0bfe5c 100644 --- a/src/KArtSell.Host/Infrastructure/RateLimiterService.cs +++ b/src/KArtSell.Host/Infrastructure/RateLimiterService.cs @@ -1,4 +1,5 @@ using Dapper; +using KArtSell.BuildingBlocks.Time; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Npgsql; @@ -13,6 +14,7 @@ namespace KArtSell.Host.Infrastructure; public class RateLimiterService { private readonly NpgsqlDataSource _dataSource; + private readonly IClock _clock; private readonly ILogger _logger; private static readonly Dictionary ApiConfigs = new() @@ -34,9 +36,10 @@ public class RateLimiterService new EventId(2, nameof(LogQuotaExceeded)), "Rate limit: {ApiName} quota exceeded, retry after {RetryAfter}s"); - public RateLimiterService(NpgsqlDataSource dataSource, ILogger logger) + public RateLimiterService(NpgsqlDataSource dataSource, IClock clock, ILogger logger) { _dataSource = dataSource; + _clock = clock; _logger = logger; } @@ -66,7 +69,7 @@ public class RateLimiterService await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); var result = await connection.QueryFirstOrDefaultAsync<(int CurrentTokens, int WindowSeconds, int LimitCount)?>( sql, - new { apiName = apiName.ToLower(), now = DateTime.UtcNow }, + new { apiName = apiName.ToLower(), now = _clock.UtcNow.UtcDateTime }, commandTimeout: 5); if (result == null) @@ -106,7 +109,7 @@ public class RateLimiterService await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); await connection.ExecuteAsync( sql, - new { apiName = apiName.ToLower(), limit = config.Limit, now = DateTime.UtcNow }, + new { apiName = apiName.ToLower(), limit = config.Limit, now = _clock.UtcNow.UtcDateTime }, commandTimeout: 5); _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( 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); } @@ -148,7 +151,7 @@ public class RateLimiterService await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); await connection.ExecuteAsync( sql, - new { apiName = apiName.ToLower(), action, now = DateTime.UtcNow }, + new { apiName = apiName.ToLower(), action, now = _clock.UtcNow.UtcDateTime }, commandTimeout: 5); } catch (Exception ex) diff --git a/src/KArtSell.Host/Jobs/OpenDartDailyBatchJob.cs b/src/KArtSell.Host/Jobs/OpenDartDailyBatchJob.cs index 29a06073..8ceda6d3 100644 --- a/src/KArtSell.Host/Jobs/OpenDartDailyBatchJob.cs +++ b/src/KArtSell.Host/Jobs/OpenDartDailyBatchJob.cs @@ -1,5 +1,6 @@ using Dapper; using Hangfire; +using KArtSell.BuildingBlocks.Time; using KArtSell.Host.Observability; using Microsoft.Extensions.Logging; using Npgsql; @@ -15,6 +16,7 @@ public class OpenDartDailyBatchJob { private readonly OpenDartService _openDart; private readonly NpgsqlDataSource _dataSource; + private readonly IClock _clock; private readonly ILogger _logger; private static readonly Action LogBatchStart = @@ -32,16 +34,18 @@ public class OpenDartDailyBatchJob public OpenDartDailyBatchJob( OpenDartService openDart, NpgsqlDataSource dataSource, + IClock clock, ILogger logger) { _openDart = openDart; _dataSource = dataSource; + _clock = clock; _logger = logger; } public async Task ExecuteAsync(CancellationToken cancellationToken = default) { - var batchDate = DateOnly.FromDateTime(DateTime.UtcNow); + var batchDate = DateOnly.FromDateTime(_clock.UtcNow.UtcDateTime); var quotaLimit = 1000; // 1. Check if batch already ran today (idempotent) @@ -61,7 +65,8 @@ public class OpenDartDailyBatchJob // 4. Fetch latest quarterly data for each ticker 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) { @@ -100,7 +105,7 @@ public class OpenDartDailyBatchJob await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); var tickers = await connection.QueryAsync( sql, - new { now = DateTime.UtcNow }, + new { now = _clock.UtcNow.UtcDateTime }, commandTimeout: 5); return tickers.ToList(); @@ -161,10 +166,4 @@ public class OpenDartDailyBatchJob commandTimeout: 5); } - private static string GetCurrentQuarter() - { - var now = DateTime.UtcNow; - var quarter = (now.Month - 1) / 3 + 1; - return $"{now.Year}-Q{quarter}"; - } } diff --git a/src/KArtSell.Host/Observability/OpenDartService.cs b/src/KArtSell.Host/Observability/OpenDartService.cs index 801e4c1f..c90fcfe0 100644 --- a/src/KArtSell.Host/Observability/OpenDartService.cs +++ b/src/KArtSell.Host/Observability/OpenDartService.cs @@ -1,4 +1,5 @@ using Dapper; +using KArtSell.BuildingBlocks.Time; using Microsoft.AspNetCore.Authentication; using Npgsql; using Microsoft.Extensions.Logging; @@ -13,6 +14,7 @@ public class OpenDartService { private readonly NpgsqlDataSource _dataSource; private readonly HttpClient _httpClient; + private readonly IClock _clock; private readonly string _apiKey; private readonly ILogger _logger; @@ -35,10 +37,12 @@ public class OpenDartService public OpenDartService( NpgsqlDataSource dataSource, HttpClient httpClient, + IClock clock, ILogger logger) { _dataSource = dataSource; _httpClient = httpClient; + _clock = clock; _apiKey = Environment.GetEnvironmentVariable("OPENDART_API_KEY") ?? throw new InvalidOperationException("OPENDART_API_KEY required"); _logger = logger; } @@ -89,7 +93,7 @@ public class OpenDartService #pragma warning disable DAP005 var json = await connection.QueryFirstOrDefaultAsync( sql, - new { ticker, quarter = quarterKey, now = DateTime.UtcNow }, + new { ticker, quarter = quarterKey, now = _clock.UtcNow.UtcDateTime }, commandTimeout: 5); if (json == null) return null; @@ -137,7 +141,7 @@ public class OpenDartService """; var dataJson = System.Text.Json.JsonSerializer.Serialize(data); - var now = DateTime.UtcNow; + var now = _clock.UtcNow.UtcDateTime; var expiresAt = now.AddDays(CacheTtlDays); await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); diff --git a/tests/KArtSell.Integration.Tests/CircuitBreakerTests.cs b/tests/KArtSell.Integration.Tests/CircuitBreakerTests.cs index 950c45da..ff4b9b1c 100644 --- a/tests/KArtSell.Integration.Tests/CircuitBreakerTests.cs +++ b/tests/KArtSell.Integration.Tests/CircuitBreakerTests.cs @@ -14,7 +14,7 @@ public class CircuitBreakerTests : IAsyncLifetime public CircuitBreakerTests(DatabaseFixture fixture) { _dataSource = fixture.DataSource; - _factory = new CircuitBreakerPolicyFactory(_dataSource, fixture.Logger()); + _factory = new CircuitBreakerPolicyFactory(_dataSource, fixture.Clock(), fixture.Logger()); } public async Task InitializeAsync() diff --git a/tests/KArtSell.Integration.Tests/DatabaseFixture.cs b/tests/KArtSell.Integration.Tests/DatabaseFixture.cs index 101f4fa8..e09f75ea 100644 --- a/tests/KArtSell.Integration.Tests/DatabaseFixture.cs +++ b/tests/KArtSell.Integration.Tests/DatabaseFixture.cs @@ -1,4 +1,5 @@ using Dapper; +using KArtSell.BuildingBlocks.Time; using Microsoft.Extensions.Logging; using Npgsql; using System; @@ -41,6 +42,16 @@ public class DatabaseFixture : IAsyncLifetime { return new XunitLogger(); } + + public IClock Clock() + { + return new TestClock(); + } +} + +internal sealed class TestClock : IClock +{ + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; } internal sealed class XunitLogger : ILogger diff --git a/tests/KArtSell.Integration.Tests/ObservabilityMetricsTests.cs b/tests/KArtSell.Integration.Tests/ObservabilityMetricsTests.cs index afbb3295..42fd8d54 100644 --- a/tests/KArtSell.Integration.Tests/ObservabilityMetricsTests.cs +++ b/tests/KArtSell.Integration.Tests/ObservabilityMetricsTests.cs @@ -16,8 +16,8 @@ public class ObservabilityMetricsTests : IAsyncLifetime public ObservabilityMetricsTests(DatabaseFixture fixture) { _dataSource = fixture.DataSource; - _policy = new MetricsPolicy(); - _sql = new MetricsSql(_dataSource); + _policy = new MetricsPolicy(fixture.Clock()); + _sql = new MetricsSql(_dataSource, fixture.Clock()); } public async Task InitializeAsync() diff --git a/tests/KArtSell.Integration.Tests/OpenDartServiceTests.cs b/tests/KArtSell.Integration.Tests/OpenDartServiceTests.cs index 838fb9ed..a1f3bf04 100644 --- a/tests/KArtSell.Integration.Tests/OpenDartServiceTests.cs +++ b/tests/KArtSell.Integration.Tests/OpenDartServiceTests.cs @@ -17,7 +17,7 @@ public class OpenDartServiceTests : IAsyncLifetime // Set test API key to avoid initialization error if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("OPENDART_API_KEY"))) Environment.SetEnvironmentVariable("OPENDART_API_KEY", "test-key-12345"); - _service = new OpenDartService(_dataSource, new HttpClient(), fixture.Logger()); + _service = new OpenDartService(_dataSource, new HttpClient(), fixture.Clock(), fixture.Logger()); } public async Task InitializeAsync() diff --git a/tests/KArtSell.Integration.Tests/RateLimiterServiceTests.cs b/tests/KArtSell.Integration.Tests/RateLimiterServiceTests.cs index fc283c84..fc32ba67 100644 --- a/tests/KArtSell.Integration.Tests/RateLimiterServiceTests.cs +++ b/tests/KArtSell.Integration.Tests/RateLimiterServiceTests.cs @@ -14,7 +14,7 @@ public class RateLimiterServiceTests : IAsyncLifetime public RateLimiterServiceTests(DatabaseFixture fixture) { _dataSource = fixture.DataSource; - _service = new RateLimiterService(_dataSource, fixture.Logger()); + _service = new RateLimiterService(_dataSource, fixture.Clock(), fixture.Logger()); } public async Task InitializeAsync()