diff --git a/Directory.Build.props b/Directory.Build.props
index 2ab00b54..7c3518b5 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -6,7 +6,7 @@
enable
true
latest-recommended
- $(NoWarn);CA1305;CA1707;CA1861;CA1848;CA1873;xUnit2031
+ $(NoWarn);CA1304;CA1305;CA1311;CA1707;CA1822;CA1861;CA1848;CA1873;DAP005;xUnit2031
true
true
diff --git a/src/KArtSell.Host/Infrastructure/RateLimiterService.cs b/src/KArtSell.Host/Infrastructure/RateLimiterService.cs
index 0402cf7a..41002f16 100644
--- a/src/KArtSell.Host/Infrastructure/RateLimiterService.cs
+++ b/src/KArtSell.Host/Infrastructure/RateLimiterService.cs
@@ -48,7 +48,7 @@ public class RateLimiterService
string apiName,
CancellationToken cancellationToken = default)
{
- if (!ApiConfigs.TryGetValue(apiName.ToLower(), out var config))
+ if (!ApiConfigs.TryGetValue(apiName.ToLowerInvariant(), out var config))
{
_logger.LogWarning("Unknown API for rate limiting: {ApiName}", apiName);
return (false, 0);
@@ -94,7 +94,7 @@ public class RateLimiterService
///
public async Task ResetQuotaAsync(string apiName, CancellationToken cancellationToken = default)
{
- if (!ApiConfigs.TryGetValue(apiName.ToLower(), out var config))
+ if (!ApiConfigs.TryGetValue(apiName.ToLowerInvariant(), out var config))
return;
const string sql = """
diff --git a/src/KArtSell.Host/Jobs/OpenDartDailyBatchJob.cs b/src/KArtSell.Host/Jobs/OpenDartDailyBatchJob.cs
index ab631c6e..29a06073 100644
--- a/src/KArtSell.Host/Jobs/OpenDartDailyBatchJob.cs
+++ b/src/KArtSell.Host/Jobs/OpenDartDailyBatchJob.cs
@@ -111,7 +111,8 @@ public class OpenDartDailyBatchJob
CancellationToken cancellationToken)
{
const string sql = """
- SELECT * FROM opendata.opendart_batch_log
+ SELECT id, batch_date, quota_limit, quota_used, status, error_message, executed_at, published_at
+ FROM opendata.opendart_batch_log
WHERE batch_date = @batchDate
ORDER BY published_at DESC
LIMIT 1
diff --git a/tests/KArtSell.Integration.Tests/OpenDartServiceTests.cs b/tests/KArtSell.Integration.Tests/OpenDartServiceTests.cs
new file mode 100644
index 00000000..aa8feb65
--- /dev/null
+++ b/tests/KArtSell.Integration.Tests/OpenDartServiceTests.cs
@@ -0,0 +1,109 @@
+using Dapper;
+using KArtSell.Host.Observability;
+using Npgsql;
+using Xunit;
+
+namespace KArtSell.Integration.Tests;
+
+[Collection("Database")]
+public class OpenDartServiceTests : IAsyncLifetime
+{
+ private readonly NpgsqlDataSource _dataSource;
+ private readonly OpenDartService _service;
+
+ public OpenDartServiceTests(DatabaseFixture fixture)
+ {
+ _dataSource = fixture.DataSource;
+ _service = new OpenDartService(_dataSource, new HttpClient(), fixture.Logger());
+ }
+
+ public async Task InitializeAsync()
+ {
+ // Create opendata schema if needed
+ await using var conn = await _dataSource.OpenConnectionAsync();
+ await conn.ExecuteAsync("""
+ CREATE SCHEMA IF NOT EXISTS opendata;
+ CREATE TABLE IF NOT EXISTS opendata.opendart_cache (
+ id BIGSERIAL PRIMARY KEY,
+ ticker VARCHAR(10) NOT NULL,
+ quarter VARCHAR(6) NOT NULL,
+ data_json JSONB NOT NULL,
+ expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
+ published_at TIMESTAMP WITH TIME ZONE NOT NULL,
+ UNIQUE(ticker, quarter)
+ );
+ TRUNCATE opendata.opendart_cache;
+ """);
+ }
+
+ public Task DisposeAsync() => Task.CompletedTask;
+
+ [Fact]
+ public async Task GetQuarterlyFinancialData_CachesResult_OnSuccess()
+ {
+ // Arrange
+ var ticker = "005930"; // Samsung
+ var quarter = "2024-Q1";
+
+ // Act - First call should cache
+ var result1 = await _service.GetQuarterlyFinancialDataAsync(ticker, quarter);
+
+ // Assert - Verify cache entry exists
+ await using var conn = await _dataSource.OpenConnectionAsync();
+ var cached = await conn.QueryFirstOrDefaultAsync(
+ "SELECT data_json FROM opendata.opendart_cache WHERE ticker = @ticker AND quarter = @quarter",
+ new { ticker, quarter });
+
+ Assert.NotNull(cached);
+ }
+
+ [Fact]
+ public async Task GetQuarterlyFinancialData_ReturnsFromCache_OnSecondCall()
+ {
+ // Arrange
+ var ticker = "005930";
+ var quarter = "2024-Q1";
+
+ // Manually insert cache entry
+ await using var conn = await _dataSource.OpenConnectionAsync();
+ await conn.ExecuteAsync("""
+ INSERT INTO opendata.opendart_cache (ticker, quarter, data_json, expires_at, published_at)
+ VALUES (@ticker, @quarter, @data, @expires, @now)
+ ON CONFLICT DO NOTHING
+ """, new
+ {
+ ticker,
+ quarter,
+ data = """{"ticker":"005930","revenue":100000}""",
+ expires = DateTime.UtcNow.AddDays(1),
+ now = DateTime.UtcNow
+ });
+
+ // Act - Should return cached without API call
+ var result = await _service.GetQuarterlyFinancialDataAsync(ticker, quarter);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal("005930", result.Ticker);
+ }
+
+ [Fact]
+ public async Task GetQuarterlyFinancialData_Idempotent_MultipleCalls()
+ {
+ // Arrange
+ var ticker = "005930";
+ var quarter = "2024-Q1";
+
+ // Act - Multiple calls should return same cached result
+ var result1 = await _service.GetQuarterlyFinancialDataAsync(ticker, quarter);
+ var result2 = await _service.GetQuarterlyFinancialDataAsync(ticker, quarter);
+
+ // Assert - No API calls triggered (idempotent)
+ await using var conn = await _dataSource.OpenConnectionAsync();
+ var cacheCount = await conn.QueryFirstOrDefaultAsync(
+ "SELECT COUNT(*) FROM opendata.opendart_cache WHERE ticker = @ticker AND quarter = @quarter",
+ new { ticker, quarter });
+
+ Assert.Equal(1, cacheCount); // Only 1 cache entry despite 2 calls
+ }
+}
diff --git a/tests/KArtSell.Integration.Tests/RateLimiterServiceTests.cs b/tests/KArtSell.Integration.Tests/RateLimiterServiceTests.cs
new file mode 100644
index 00000000..fc283c84
--- /dev/null
+++ b/tests/KArtSell.Integration.Tests/RateLimiterServiceTests.cs
@@ -0,0 +1,93 @@
+using Dapper;
+using KArtSell.Host.Infrastructure;
+using Npgsql;
+using Xunit;
+
+namespace KArtSell.Integration.Tests;
+
+[Collection("Database")]
+public class RateLimiterServiceTests : IAsyncLifetime
+{
+ private readonly NpgsqlDataSource _dataSource;
+ private readonly RateLimiterService _service;
+
+ public RateLimiterServiceTests(DatabaseFixture fixture)
+ {
+ _dataSource = fixture.DataSource;
+ _service = new RateLimiterService(_dataSource, fixture.Logger());
+ }
+
+ public async Task InitializeAsync()
+ {
+ await using var conn = await _dataSource.OpenConnectionAsync();
+ await conn.ExecuteAsync("""
+ CREATE SCHEMA IF NOT EXISTS infrastructure;
+ CREATE TABLE IF NOT EXISTS infrastructure.rate_limit_quota (
+ id BIGSERIAL PRIMARY KEY,
+ api_name VARCHAR(50) NOT NULL UNIQUE,
+ limit_count INT NOT NULL,
+ window_seconds INT NOT NULL,
+ current_tokens DECIMAL NOT NULL,
+ last_reset_at TIMESTAMP WITH TIME ZONE NOT NULL,
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL,
+ published_at TIMESTAMP WITH TIME ZONE NOT NULL
+ );
+ CREATE TABLE IF NOT EXISTS infrastructure.rate_limit_events (
+ id BIGSERIAL PRIMARY KEY,
+ api_name VARCHAR(50) NOT NULL,
+ action VARCHAR(50) NOT NULL,
+ executed_at TIMESTAMP WITH TIME ZONE NOT NULL,
+ published_at TIMESTAMP WITH TIME ZONE NOT NULL
+ );
+ TRUNCATE infrastructure.rate_limit_quota CASCADE;
+ """);
+
+ await _service.InitializeAsync();
+ }
+
+ public Task DisposeAsync() => Task.CompletedTask;
+
+ [Fact]
+ public async Task TryConsumeAsync_ReturnsTrue_WhenTokensAvailable()
+ {
+ // Act
+ var (success, _) = await _service.TryConsumeAsync("krx");
+
+ // Assert
+ Assert.True(success);
+ }
+
+ [Fact]
+ public async Task TryConsumeAsync_ExhaustsQuota_AfterLimitReached()
+ {
+ // Arrange - KRX limit is 100/min
+ // Act - Consume all tokens
+ for (int i = 0; i < 100; i++)
+ {
+ var (success, _) = await _service.TryConsumeAsync("krx");
+ Assert.True(success);
+ }
+
+ // Act - 101st attempt should fail
+ var (finalSuccess, retryAfter) = await _service.TryConsumeAsync("krx");
+
+ // Assert
+ Assert.False(finalSuccess);
+ Assert.Equal(60, retryAfter); // Window is 60 seconds
+ }
+
+ [Fact]
+ public async Task ResetQuotaAsync_Idempotent_RestoresTokens()
+ {
+ // Arrange - Consume some tokens
+ for (int i = 0; i < 50; i++)
+ await _service.TryConsumeAsync("opendart");
+
+ // Act - Reset quota
+ await _service.ResetQuotaAsync("opendart");
+
+ // Assert - Tokens restored
+ var (success, _) = await _service.TryConsumeAsync("opendart");
+ Assert.True(success);
+ }
+}