diff --git a/CLAUDE.md b/CLAUDE.md index 50ab8d04..809d4f16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 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 -# 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 -$env:KRX_API_KEY = "local-dev-test-key" -$env:OPENDART_API_KEY = "local-dev-test-key" -$env:KIS_API_KEY = "local-dev-test-key" -dotnet run --project src/KArtSell.Host -c Release + +# Set actual API keys from Gitea Secrets (not test keys!) +$env:KRX_API_KEY = "" +$env:OPENDART_API_KEY = "" +$env: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 -$headers = @{"X-KArtSell-User"="admin"; "X-KArtSell-Role"="Admin"} -$body = @{"modelId"="00000000-0000-0000-0000-000000000001"; ...} | ConvertTo-Json -Invoke-WebRequest -Uri "http://127.0.0.1:5002/api/shadow-runs" -Method POST -Headers $headers -Body $body +$headers = @{ + "X-KArtSell-User" = "gate3-rehearsal" + "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" ``` --- 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/Health/PingEndpoint.cs b/src/KArtSell.Host/Features/Health/PingEndpoint.cs index d8521d38..a774145f 100644 --- a/src/KArtSell.Host/Features/Health/PingEndpoint.cs +++ b/src/KArtSell.Host/Features/Health/PingEndpoint.cs @@ -15,7 +15,7 @@ public class PingEndpoint : Endpoint public override void Configure() { Get("/health/ping"); - AllowAnonymous(); + Roles("Admin", "Analyst", "System"); // Health checks require authentication } public override async Task HandleAsync(PingRequest req, CancellationToken ct) diff --git a/src/KArtSell.Host/Features/Observability/GetMetricsEndpoint.cs b/src/KArtSell.Host/Features/Observability/GetMetricsEndpoint.cs index 39254664..d52d580b 100644 --- a/src/KArtSell.Host/Features/Observability/GetMetricsEndpoint.cs +++ b/src/KArtSell.Host/Features/Observability/GetMetricsEndpoint.cs @@ -1,5 +1,6 @@ using FastEndpoints; using Microsoft.Extensions.Logging; +using KArtSell.BuildingBlocks.Observability; namespace KArtSell.Host.Features.Observability; @@ -35,8 +36,7 @@ public class GetMetricsEndpoint : Endpoint public override void Configure() { Get("/observability/metrics"); - Roles("Admin", "Analyst"); - AllowAnonymous(); // Override for demo; require auth in production + Roles("Admin", "Analyst", "Auditor"); // Financial compliance requires authentication } public override async Task HandleAsync(EmptyRequest req, CancellationToken ct) 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/Features/Observability/MetricsSql.cs b/src/KArtSell.Host/Features/Observability/MetricsSql.cs deleted file mode 100644 index a2fef771..00000000 --- a/src/KArtSell.Host/Features/Observability/MetricsSql.cs +++ /dev/null @@ -1,98 +0,0 @@ -using Dapper; -using Npgsql; - -namespace KArtSell.Host.Features.Observability; - -/// -/// Data queries for observability metrics. -/// All queries use PIT (point-in-time) pattern: published_at <= cutoff. -/// Schema-qualified, explicit columns, no SELECT *. -/// -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 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() : 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 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; - } -} diff --git a/src/KArtSell.Host/Features/ShadowRun/Endpoint.cs b/src/KArtSell.Host/Features/ShadowRun/Endpoint.cs index e3c7d4fc..fe0e7a17 100644 --- a/src/KArtSell.Host/Features/ShadowRun/Endpoint.cs +++ b/src/KArtSell.Host/Features/ShadowRun/Endpoint.cs @@ -28,7 +28,7 @@ public class InitiateShadowRunEndpoint : Endpoint(); } diff --git a/src/KArtSell.Host/Features/ShadowRun/GetShadowRunPollingEndpoint.cs b/src/KArtSell.Host/Features/ShadowRun/GetShadowRunPollingEndpoint.cs index 8ebbadbc..e44d9231 100644 --- a/src/KArtSell.Host/Features/ShadowRun/GetShadowRunPollingEndpoint.cs +++ b/src/KArtSell.Host/Features/ShadowRun/GetShadowRunPollingEndpoint.cs @@ -27,7 +27,7 @@ public class GetShadowRunPollingEndpoint : Endpoint _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/DownstreamConsumerJob.cs b/src/KArtSell.Host/Jobs/DownstreamConsumerJob.cs index 1b243265..0da4c921 100644 --- a/src/KArtSell.Host/Jobs/DownstreamConsumerJob.cs +++ b/src/KArtSell.Host/Jobs/DownstreamConsumerJob.cs @@ -86,28 +86,37 @@ public sealed class DownstreamConsumerJob( 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; } var (eventType, payloadJson) = outboxRow; - // Route to appropriate consumer - if (eventType == "ShadowRunCompleted") + // Route to appropriate consumer based on event type + switch (eventType) { - var @event = JsonSerializer.Deserialize(payloadJson) - ?? throw new InvalidOperationException($"Failed to deserialize payload for {messageId}"); + case "ShadowRunCompleted": + var shadowEvent = JsonSerializer.Deserialize(payloadJson) + ?? throw new InvalidOperationException($"Failed to deserialize {eventType} payload for {messageId}"); - await shadowRunConsumer.HandleAsync(@event, cancellationToken); - await approvalQueueConsumer.HandleAsync(@event, cancellationToken); - await auditLogConsumer.HandleAsync(@event, cancellationToken); + await shadowRunConsumer.HandleAsync(shadowEvent, cancellationToken); + await approvalQueueConsumer.HandleAsync(shadowEvent, cancellationToken); + await auditLogConsumer.HandleAsync(shadowEvent, cancellationToken); - LogMessageProcessed(logger, messageId, eventType, null); - processedCount++; - } - else - { - logger.LogWarning("Unknown event type {EventType} for message {MessageId}", eventType, messageId); + LogMessageProcessed(logger, messageId, eventType, null); + processedCount++; + break; + + case "TestEvent": + 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) 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 6268010e..42fd8d54 100644 --- a/tests/KArtSell.Integration.Tests/ObservabilityMetricsTests.cs +++ b/tests/KArtSell.Integration.Tests/ObservabilityMetricsTests.cs @@ -1,4 +1,5 @@ using Dapper; +using KArtSell.BuildingBlocks.Observability; using KArtSell.Host.Features.Observability; using Npgsql; using Xunit; @@ -15,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()