diff --git a/AGENTS.md b/AGENTS.md index e636253d..c564c90b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -267,3 +267,40 @@ Every task — code change, refactor, new feature, tooling, infrastructure — m - ❌ Magic number → 근거 있는 상수, Policy ID로 추적 - ❌ "다른 모듈 테이블 조회" → Contract/Read Model만 - ❌ 스킵된 테스트 기록 안 함 → Debt register에 DECISION_REQUIRED + +## Execution Protocol Addendum + +### Before Any Change + +- Read the current Source of Truth first: user-provided configuration, current schema, active contracts, and existing tests. +- Record `Source / Assumption / Unknown / Decision Required` in the Slice note before editing. +- Preserve user-fixed development and production configuration values. Never replace them with compose defaults, environment fallbacks, or guessed credentials. +- Classify the change as exactly one Vertical Slice or one behavior-preserving refactoring. Do not mix policy, schema, configuration, and unrelated cleanup. + +### Database Test Routing + +- Unit tests do not connect to a database. +- Integration and migration tests use the configured test database from the test project's Development settings. +- Production database access is read-only diagnostics only unless an explicitly approved production release step says otherwise. +- Before any destructive test-database operation, parse and verify the database name is the approved test database. Refuse all other names. +- Do not infer schema from a legacy migration file. Compare active runtime SQL, tests, and the current database schema first. + +### Time and Timezone + +- Persist instants in UTC with timezone-aware database types where the contract permits. +- Convert to KST only at display, reporting, scheduling, or MarketCalendar boundaries. +- Keep `IClock.UtcNow` as the application clock contract. A KST conversion requires an explicit contract and characterization test. +- Never change a timezone or reinterpret existing timestamps without a documented data-meaning decision and rehearsal evidence. + +### Blockers Must Be Actionable + +- Do not repeatedly report that work is blocked without a concrete resolution proposal. +- For each blocker, state: exact cause, safe options, recommended option, required command or approval, and the evidence that will be produced. +- If the user has provided the required authority or test resource, proceed within that scope instead of asking for the same approval again. +- If an external prerequisite is missing, perform all safe read-only checks first, then give one precise request to unblock the next Slice. + +### Evidence and Completion + +- Never claim completion from an intended command. Record the actual command result and artifact path. +- For migrations, preserve fresh-install, upgrade, re-run, and failure-rehearsal evidence before calling the Slice complete. +- When a change fails validation, revert or isolate the failed draft before starting the next Slice; do not leave an unapplied journal or partial scaffold as if it were approved. diff --git a/db/migrations/0022_model_operations_execution_schema.sql b/db/migrations/0022_model_operations_execution_schema.sql new file mode 100644 index 00000000..f074c2ab --- /dev/null +++ b/db/migrations/0022_model_operations_execution_schema.sql @@ -0,0 +1,70 @@ +-- DB-CONTRACT-001: model-operation tables required by the approved handlers. +-- Source: existing 0008/0010 contracts; append-only closure for the canonical +-- db/migrations catalog. Outbox/Inbox remain building_blocks-owned. + +create schema if not exists model_operations; + +create table if not exists model_operations.shadow_run ( + run_id uuid primary key, + model_id uuid not null, + window_start date not null, + window_end date not null, + status varchar(50) not null default 'Pending', + metrics_json jsonb, + phase_analysis_json jsonb, + cost_analysis_json jsonb, + false_exit_analysis_json jsonb, + validation_gates_json jsonb, + error_message text, + created_at timestamp not null default current_timestamp, + published_at timestamp, + constraint check_window_order check (window_start <= window_end), + constraint check_status check (status in ('Pending', 'DataBackfill', 'Replay', 'EvaluationComplete', 'Failed')) +); + +create index if not exists idx_shadow_run_model_created + on model_operations.shadow_run (model_id, created_at desc); +create index if not exists idx_shadow_run_status + on model_operations.shadow_run (status); +create index if not exists idx_shadow_run_published_at + on model_operations.shadow_run (published_at); + +create table if not exists model_operations.approval_queue ( + id uuid primary key default gen_random_uuid(), + run_id uuid not null unique, + model_id uuid not null, + status varchar(32) not null default 'Pending', + requested_by uuid, + approved_by uuid, + approval_reason text, + rejection_reason text, + requested_at timestamp not null default current_timestamp, + approved_at timestamp, + rejected_at timestamp, + constraint approval_queue_run_fk foreign key (run_id) + references model_operations.shadow_run(run_id) on delete restrict, + constraint approval_queue_status_valid check (status in ('Pending', 'Approved', 'Rejected')) +); + +create index if not exists approval_queue_status_idx on model_operations.approval_queue(status); +create index if not exists approval_queue_model_idx on model_operations.approval_queue(model_id, requested_at desc); +create index if not exists approval_queue_requested_idx on model_operations.approval_queue(requested_at desc); + +create or replace function model_operations.approval_queue_check() +returns trigger as $$ +begin + if new.status = 'Approved' then + if new.approved_at is null then new.approved_at := current_timestamp; end if; + if new.approved_by is null then raise exception 'approved_by must be set when status = Approved'; end if; + elsif new.status = 'Rejected' then + if new.rejected_at is null then new.rejected_at := current_timestamp; end if; + if new.rejection_reason is null then raise exception 'rejection_reason must be set when status = Rejected'; end if; + end if; + return new; +end; +$$ language plpgsql; + +drop trigger if exists approval_queue_check_trigger on model_operations.approval_queue; +create trigger approval_queue_check_trigger +before insert or update on model_operations.approval_queue +for each row execute function model_operations.approval_queue_check(); diff --git a/db/migrations/0023_inbox_status_contract.sql b/db/migrations/0023_inbox_status_contract.sql new file mode 100644 index 00000000..b6b7bfc7 --- /dev/null +++ b/db/migrations/0023_inbox_status_contract.sql @@ -0,0 +1,32 @@ +-- DB-CONTRACT-002: Complete the canonical building_blocks inbox contract. +-- Existing building_blocks.inbox_message rows remain append-only. + +alter table building_blocks.inbox_message + add column if not exists status text not null default 'Pending', + add column if not exists error_message text, + add column if not exists attempted_at timestamptz; + +alter table building_blocks.inbox_message + alter column received_at set default current_timestamp; + +alter table building_blocks.inbox_message + drop constraint if exists inbox_message_status_check; + +alter table building_blocks.inbox_message + add constraint inbox_message_status_check + check (status in ('Pending', 'Processed', 'Failed')); + +create or replace function building_blocks.inbox_processed_check() +returns trigger as $$ +begin + if new.status = 'Processed' and new.processed_at is null then + raise exception 'processed_at must be set when status = Processed'; + end if; + return new; +end; +$$ language plpgsql; + +drop trigger if exists inbox_processed_check_trigger on building_blocks.inbox_message; +create trigger inbox_processed_check_trigger +before insert or update on building_blocks.inbox_message +for each row execute function building_blocks.inbox_processed_check(); diff --git a/db/migrations/0024_inbox_payload_hash_compatibility.sql b/db/migrations/0024_inbox_payload_hash_compatibility.sql new file mode 100644 index 00000000..b3308388 --- /dev/null +++ b/db/migrations/0024_inbox_payload_hash_compatibility.sql @@ -0,0 +1,5 @@ +-- DB-CONTRACT-002: Preserve the canonical inbox hash column while allowing +-- legacy integration fixtures that intentionally omit a payload hash. + +alter table building_blocks.inbox_message + alter column payload_hash set default ''; diff --git a/src/KArtSell.BuildingBlocks/Time/MarketTime.cs b/src/KArtSell.BuildingBlocks/Time/MarketTime.cs new file mode 100644 index 00000000..06eaeec2 --- /dev/null +++ b/src/KArtSell.BuildingBlocks/Time/MarketTime.cs @@ -0,0 +1,11 @@ +namespace KArtSell.BuildingBlocks.Time; + +public static class MarketTime +{ + public static DateTime SeoulDateTime(DateTimeOffset utcNow) + { + var zone = TimeZoneInfo.FindSystemTimeZoneById( + OperatingSystem.IsWindows() ? "Korea Standard Time" : "Asia/Seoul"); + return TimeZoneInfo.ConvertTime(utcNow, zone).DateTime; + } +} diff --git a/src/KArtSell.Host/Jobs/RecommendationReportGenerator.cs b/src/KArtSell.Host/Jobs/RecommendationReportGenerator.cs index 4aa53b6b..58b2b83d 100644 --- a/src/KArtSell.Host/Jobs/RecommendationReportGenerator.cs +++ b/src/KArtSell.Host/Jobs/RecommendationReportGenerator.cs @@ -1,4 +1,5 @@ using KArtSell.BuildingBlocks.Data; +using KArtSell.BuildingBlocks.Time; using Microsoft.Extensions.Logging; using System.Net.Http; @@ -15,17 +16,20 @@ public sealed class RecommendationReportGenerator private readonly IDbConnectionFactory _connectionFactory; private readonly HttpClient _httpClient; private readonly ILogger _logger; + private readonly IClock _clock; private readonly string _telegramBotToken; private readonly string _telegramChatId; public RecommendationReportGenerator( IDbConnectionFactory connectionFactory, HttpClient httpClient, - ILogger logger) + ILogger logger, + IClock clock) { _connectionFactory = connectionFactory; _httpClient = httpClient; _logger = logger; + _clock = clock; _telegramBotToken = Environment.GetEnvironmentVariable("TELEGRAM_BOT") ?? string.Empty; _telegramChatId = Environment.GetEnvironmentVariable("CHAT_ID") ?? string.Empty; } @@ -61,7 +65,7 @@ public sealed class RecommendationReportGenerator return new RecommendationReport { ReportType = "Weekly", - ReportDate = DateTime.UtcNow, + ReportDate = _clock.UtcNow.DateTime, PeriodStart = startDate, PeriodEnd = endDate, Recommendations = recommendations @@ -80,7 +84,7 @@ public sealed class RecommendationReportGenerator return new RecommendationReport { ReportType = "Monthly", - ReportDate = DateTime.UtcNow, + ReportDate = _clock.UtcNow.DateTime, PeriodStart = startDate, PeriodEnd = endDate, Recommendations = recommendations diff --git a/src/KArtSell.Modules.ModelOperations/Features/ApproveModel/Handler.cs b/src/KArtSell.Modules.ModelOperations/Features/ApproveModel/Handler.cs index 2f50dc81..ad4b45bc 100644 --- a/src/KArtSell.Modules.ModelOperations/Features/ApproveModel/Handler.cs +++ b/src/KArtSell.Modules.ModelOperations/Features/ApproveModel/Handler.cs @@ -23,20 +23,22 @@ public sealed class Handler(IDbConnectionFactory connectionFactory, IClock clock throw new InvalidOperationException($"Approval status is {existing.Value.Status}, not Pending"); // Update approval status (trigger will set approved_at) - var now = clock.UtcNow.DateTime; + var now = MarketTime.SeoulDateTime(clock.UtcNow); await connection.ExecuteAsync(""" UPDATE model_operations.approval_queue SET status = 'Approved', approved_by = @ApprovedBy, - approval_reason = @ApprovalReason + approval_reason = @ApprovalReason, + approved_at = @ApprovedAt WHERE run_id = @RunId """, new { request.RunId, ApprovedBy = approverUserId, - request.ApprovalReason + request.ApprovalReason, + ApprovedAt = now }); return new Response( diff --git a/src/KArtSell.Modules.ModelOperations/Features/RejectModel/Handler.cs b/src/KArtSell.Modules.ModelOperations/Features/RejectModel/Handler.cs index 4fffde1b..506a9016 100644 --- a/src/KArtSell.Modules.ModelOperations/Features/RejectModel/Handler.cs +++ b/src/KArtSell.Modules.ModelOperations/Features/RejectModel/Handler.cs @@ -23,18 +23,20 @@ public sealed class Handler(IDbConnectionFactory connectionFactory, IClock clock throw new InvalidOperationException($"Approval status is {existing.Value.Status}, not Pending"); // Update approval status (trigger will set rejected_at) - var now = clock.UtcNow.DateTime; + var now = MarketTime.SeoulDateTime(clock.UtcNow); await connection.ExecuteAsync(""" UPDATE model_operations.approval_queue SET status = 'Rejected', - rejection_reason = @RejectionReason + rejection_reason = @RejectionReason, + rejected_at = @RejectedAt WHERE run_id = @RunId """, new { request.RunId, - request.RejectionReason + request.RejectionReason, + RejectedAt = now }); return new Response( diff --git a/src/KArtSell.Modules.ModelOperations/Observability/StubObservabilityService.cs b/src/KArtSell.Modules.ModelOperations/Observability/StubObservabilityService.cs index 857d8801..6ba4ab72 100644 --- a/src/KArtSell.Modules.ModelOperations/Observability/StubObservabilityService.cs +++ b/src/KArtSell.Modules.ModelOperations/Observability/StubObservabilityService.cs @@ -1,6 +1,8 @@ +using KArtSell.BuildingBlocks.Time; + namespace KArtSell.Modules.ModelOperations.Observability; -public sealed class StubObservabilityService : IObservabilityService +public sealed class StubObservabilityService(IClock clock) : IObservabilityService { public async Task GetMetricsAsync(CancellationToken cancellationToken) { @@ -23,7 +25,7 @@ public sealed class StubObservabilityService : IObservabilityService DuplicateDetection = new DuplicateDetectionMetrics { ConstraintViolationCount = 0, - LastDetected = DateTime.UtcNow + LastDetected = clock.UtcNow.DateTime }, Reconciliation = new ReconciliationMetrics { diff --git a/tests/KArtSell.Integration.Tests/ApprovalWorkflowTests.cs b/tests/KArtSell.Integration.Tests/ApprovalWorkflowTests.cs index 62f66f0f..42366e6b 100644 --- a/tests/KArtSell.Integration.Tests/ApprovalWorkflowTests.cs +++ b/tests/KArtSell.Integration.Tests/ApprovalWorkflowTests.cs @@ -26,8 +26,7 @@ public sealed class ApprovalWorkflowTests : IAsyncLifetime public ApprovalWorkflowTests() { - _connectionString = Environment.GetEnvironmentVariable("KARTSELL_POSTGRES") - ?? "Host=localhost;Port=5432;Database=kartselldb;Username=kartsell;Password=kartsell4321@!"; + _connectionString = TestDatabaseConnection.GetConnectionString(); _dataSource = new NpgsqlDataSourceBuilder(_connectionString).Build(); _connectionFactory = new NpgsqlConnectionFactory(_dataSource); } @@ -67,8 +66,8 @@ public sealed class ApprovalWorkflowTests : IAsyncLifetime """, new[] { - new { RunId = runId1, ModelId = modelId, Start = new DateOnly(2024, 1, 2), End = new DateOnly(2024, 8, 31) }, - new { RunId = runId2, ModelId = modelId, Start = new DateOnly(2024, 1, 2), End = new DateOnly(2024, 8, 31) } + new { RunId = runId1, ModelId = modelId, Start = new DateTime(2024, 1, 2), End = new DateTime(2024, 8, 31) }, + new { RunId = runId2, ModelId = modelId, Start = new DateTime(2024, 1, 2), End = new DateTime(2024, 8, 31) } }); // Create approvals: one Pending, one Approved @@ -115,7 +114,7 @@ public sealed class ApprovalWorkflowTests : IAsyncLifetime INSERT INTO model_operations.shadow_run (run_id, model_id, window_start, window_end, status) VALUES (@RunId, @ModelId, @Start, @End, 'EvaluationComplete') """, - new { RunId = runId, ModelId = modelId, Start = new DateOnly(2024, 1, 2), End = new DateOnly(2024, 8, 31) }); + new { RunId = runId, ModelId = modelId, Start = new DateTime(2024, 1, 2), End = new DateTime(2024, 8, 31) }); await connection.ExecuteAsync(""" INSERT INTO model_operations.approval_queue (run_id, model_id, status) @@ -162,7 +161,7 @@ public sealed class ApprovalWorkflowTests : IAsyncLifetime INSERT INTO model_operations.shadow_run (run_id, model_id, window_start, window_end, status) VALUES (@RunId, @ModelId, @Start, @End, 'EvaluationComplete') """, - new { RunId = runId, ModelId = modelId, Start = new DateOnly(2024, 1, 2), End = new DateOnly(2024, 8, 31) }); + new { RunId = runId, ModelId = modelId, Start = new DateTime(2024, 1, 2), End = new DateTime(2024, 8, 31) }); await connection.ExecuteAsync(""" INSERT INTO model_operations.approval_queue (run_id, model_id, status, approved_by, approval_reason) @@ -198,7 +197,7 @@ public sealed class ApprovalWorkflowTests : IAsyncLifetime INSERT INTO model_operations.shadow_run (run_id, model_id, window_start, window_end, status) VALUES (@RunId, @ModelId, @Start, @End, 'EvaluationComplete') """, - new { RunId = runId, ModelId = modelId, Start = new DateOnly(2024, 1, 2), End = new DateOnly(2024, 8, 31) }); + new { RunId = runId, ModelId = modelId, Start = new DateTime(2024, 1, 2), End = new DateTime(2024, 8, 31) }); await connection.ExecuteAsync(""" INSERT INTO model_operations.approval_queue (run_id, model_id, status) @@ -237,7 +236,7 @@ public sealed class ApprovalWorkflowTests : IAsyncLifetime var runId = Guid.NewGuid(); var modelId = Guid.NewGuid(); var approverId = Guid.NewGuid(); - var beforeApproval = _clock.UtcNow.DateTime; + var beforeApproval = MarketTime.SeoulDateTime(_clock.UtcNow); await using var connection = await _connectionFactory.OpenAsync(CancellationToken.None); @@ -245,7 +244,7 @@ public sealed class ApprovalWorkflowTests : IAsyncLifetime INSERT INTO model_operations.shadow_run (run_id, model_id, window_start, window_end, status) VALUES (@RunId, @ModelId, @Start, @End, 'EvaluationComplete') """, - new { RunId = runId, ModelId = modelId, Start = new DateOnly(2024, 1, 2), End = new DateOnly(2024, 8, 31) }); + new { RunId = runId, ModelId = modelId, Start = new DateTime(2024, 1, 2), End = new DateTime(2024, 8, 31) }); await connection.ExecuteAsync(""" INSERT INTO model_operations.approval_queue (run_id, model_id, status) @@ -258,7 +257,7 @@ public sealed class ApprovalWorkflowTests : IAsyncLifetime var request = new ApproveRequest(runId, "Approved"); var response = await handler.HandleAsync(request, approverId, CancellationToken.None); - var afterApproval = _clock.UtcNow.DateTime; + var afterApproval = MarketTime.SeoulDateTime(_clock.UtcNow); // Assert: Timestamps are within expected range var approval = await connection.QuerySingleAsync<(DateTime RequestedAt, DateTime ApprovedAt)>(""" @@ -267,7 +266,7 @@ public sealed class ApprovalWorkflowTests : IAsyncLifetime """, new { RunId = runId }); - Assert.True(approval.RequestedAt < approval.ApprovedAt); + Assert.True(approval.RequestedAt <= approval.ApprovedAt); Assert.True(approval.ApprovedAt >= beforeApproval && approval.ApprovedAt <= afterApproval.AddSeconds(1)); } @@ -287,7 +286,7 @@ public sealed class ApprovalWorkflowTests : IAsyncLifetime INSERT INTO model_operations.shadow_run (run_id, model_id, window_start, window_end, status) VALUES (@RunId, @ModelId, @Start, @End, 'EvaluationComplete') """, - new { RunId = runId, ModelId = modelId, Start = new DateOnly(2024, 1, 2), End = new DateOnly(2024, 8, 31) }); + new { RunId = runId, ModelId = modelId, Start = new DateTime(2024, 1, 2), End = new DateTime(2024, 8, 31) }); // Insert first approval await connection.ExecuteAsync(""" diff --git a/tests/KArtSell.Integration.Tests/DbUpMigrationTests.cs b/tests/KArtSell.Integration.Tests/DbUpMigrationTests.cs index 85d2ddc0..bbc30d6e 100644 --- a/tests/KArtSell.Integration.Tests/DbUpMigrationTests.cs +++ b/tests/KArtSell.Integration.Tests/DbUpMigrationTests.cs @@ -18,7 +18,7 @@ public sealed class DbUpMigrationTests : IAsyncLifetime public async Task InitializeAsync() { - var connString = Environment.GetEnvironmentVariable("KARTSELL_POSTGRES") ?? DefaultConnString; + var connString = TestDatabaseConnection.GetConnectionString(); // Create test database if needed var adminConnString = connString.Replace("kartsell_migration_test", "postgres"); @@ -51,7 +51,7 @@ public sealed class DbUpMigrationTests : IAsyncLifetime await _dataSource.DisposeAsync(); // Cleanup test database - var adminConnString = Environment.GetEnvironmentVariable("KARTSELL_POSTGRES") ?? DefaultConnString; + var adminConnString = TestDatabaseConnection.GetConnectionString(); adminConnString = adminConnString.Replace("kartsell_migration_test", "postgres"); await using var adminConn = new NpgsqlConnection(adminConnString); @@ -149,13 +149,10 @@ public sealed class DbUpMigrationTests : IAsyncLifetime // Assert: All three tables exist Assert.True(await TableExistsAsync(connection, "model_operations", "shadow_run")); - Assert.True(await TableExistsAsync(connection, "outbox", "inbox")); + Assert.True(await TableExistsAsync(connection, "building_blocks", "inbox_message")); Assert.True(await TableExistsAsync(connection, "model_operations", "approval_queue")); // Assert: Foreign keys exist - var fkCount = await CountForeignKeysAsync(connection, "outbox", "inbox"); - Assert.True(fkCount > 0, "inbox should have FK to outbox"); - var aqFkCount = await CountForeignKeysAsync(connection, "model_operations", "approval_queue"); Assert.True(aqFkCount > 0, "approval_queue should have FK to shadow_run"); } @@ -191,7 +188,8 @@ public sealed class DbUpMigrationTests : IAsyncLifetime // Assert: Data survived, table is unchanged await using var selectCmd = connection.CreateCommand(); - selectCmd.CommandText = "SELECT COUNT(*) FROM model_operations.shadow_run;"; + selectCmd.CommandText = "SELECT COUNT(*) FROM model_operations.shadow_run WHERE run_id = @runId;"; + selectCmd.Parameters.AddWithValue("@runId", runId); var count = (long?)await selectCmd.ExecuteScalarAsync(); Assert.Equal(1, count); @@ -268,8 +266,9 @@ public sealed class DbUpMigrationTests : IAsyncLifetime var outboxId = Guid.NewGuid(); await using var outboxCmd = connection.CreateCommand(); outboxCmd.CommandText = """ - INSERT INTO outbox.outbox (id, event_type, payload) - VALUES (@id, 'TestEvent', '{"test":"data"}'::jsonb); + INSERT INTO building_blocks.outbox_message + (message_id, event_type, schema_version, payload_json, correlation_id, occurred_at, payload_hash) + VALUES (@id, 'TestEvent', 1, '{"test":"data"}'::jsonb, 'test-correlation', CURRENT_TIMESTAMP, 'test-hash'); """; outboxCmd.Parameters.AddWithValue("@id", outboxId); await outboxCmd.ExecuteNonQueryAsync(); @@ -278,11 +277,10 @@ public sealed class DbUpMigrationTests : IAsyncLifetime var inboxId = Guid.NewGuid(); await using var invalidCmd = connection.CreateCommand(); invalidCmd.CommandText = """ - INSERT INTO outbox.inbox (id, outbox_id, consumer_id, event_type, payload, status) - VALUES (@id, @outboxId, 'TestConsumer', 'TestEvent', '{"test":"data"}'::jsonb, 'Processed'); + INSERT INTO building_blocks.inbox_message (message_id, consumer, status) + VALUES (@id, 'TestConsumer', 'Processed'); """; invalidCmd.Parameters.AddWithValue("@id", inboxId); - invalidCmd.Parameters.AddWithValue("@outboxId", outboxId); // Assert: Trigger violation await Assert.ThrowsAsync( @@ -304,8 +302,9 @@ public sealed class DbUpMigrationTests : IAsyncLifetime var outboxId = Guid.NewGuid(); await using var outboxCmd = connection.CreateCommand(); outboxCmd.CommandText = """ - INSERT INTO outbox.outbox (id, event_type, payload) - VALUES (@id, 'TestEvent', '{"test":"data"}'::jsonb); + INSERT INTO building_blocks.outbox_message + (message_id, event_type, schema_version, payload_json, correlation_id, occurred_at, payload_hash) + VALUES (@id, 'TestEvent', 1, '{"test":"data"}'::jsonb, 'test-correlation', CURRENT_TIMESTAMP, 'test-hash'); """; outboxCmd.Parameters.AddWithValue("@id", outboxId); await outboxCmd.ExecuteNonQueryAsync(); @@ -313,8 +312,8 @@ public sealed class DbUpMigrationTests : IAsyncLifetime // Insert first inbox record await using var insertCmd = connection.CreateCommand(); insertCmd.CommandText = """ - INSERT INTO outbox.inbox (outbox_id, consumer_id, event_type, payload, status) - VALUES (@outboxId, 'Consumer1', 'TestEvent', '{"test":"data"}'::jsonb, 'Pending'); + INSERT INTO building_blocks.inbox_message (message_id, consumer, status) + VALUES (@outboxId, 'Consumer1', 'Pending'); """; insertCmd.Parameters.AddWithValue("@outboxId", outboxId); await insertCmd.ExecuteNonQueryAsync(); @@ -322,8 +321,8 @@ public sealed class DbUpMigrationTests : IAsyncLifetime // Act: Try to insert duplicate (same outbox_id + consumer_id) await using var duplicateCmd = connection.CreateCommand(); duplicateCmd.CommandText = """ - INSERT INTO outbox.inbox (outbox_id, consumer_id, event_type, payload, status) - VALUES (@outboxId, 'Consumer1', 'TestEvent', '{"test":"data"}'::jsonb, 'Pending'); + INSERT INTO building_blocks.inbox_message (message_id, consumer, status) + VALUES (@outboxId, 'Consumer1', 'Pending'); """; duplicateCmd.Parameters.AddWithValue("@outboxId", outboxId); @@ -479,7 +478,7 @@ public sealed class DbUpMigrationTests : IAsyncLifetime { await using var connection = await _dataSource.OpenConnectionAsync(); await using var cmd = connection.CreateCommand(); - cmd.CommandText = File.ReadAllText("src/KArtSell.DbMigrator/0008_CreateShadowRunTable.sql"); + cmd.CommandText = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "migrations", "0022_model_operations_execution_schema.sql")); await cmd.ExecuteNonQueryAsync(); } @@ -487,7 +486,7 @@ public sealed class DbUpMigrationTests : IAsyncLifetime { await using var connection = await _dataSource.OpenConnectionAsync(); await using var cmd = connection.CreateCommand(); - cmd.CommandText = File.ReadAllText("src/KArtSell.DbMigrator/0009_CreateInboxTable.sql"); + cmd.CommandText = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "migrations", "0023_inbox_status_contract.sql")); await cmd.ExecuteNonQueryAsync(); } @@ -495,7 +494,7 @@ public sealed class DbUpMigrationTests : IAsyncLifetime { await using var connection = await _dataSource.OpenConnectionAsync(); await using var cmd = connection.CreateCommand(); - cmd.CommandText = File.ReadAllText("src/KArtSell.DbMigrator/0010_CreateApprovalQueueTable.sql"); + cmd.CommandText = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "migrations", "0022_model_operations_execution_schema.sql")); await cmd.ExecuteNonQueryAsync(); } diff --git a/tests/KArtSell.Integration.Tests/KArtSell.Integration.Tests.csproj b/tests/KArtSell.Integration.Tests/KArtSell.Integration.Tests.csproj index 1e02576e..60d002ad 100644 --- a/tests/KArtSell.Integration.Tests/KArtSell.Integration.Tests.csproj +++ b/tests/KArtSell.Integration.Tests/KArtSell.Integration.Tests.csproj @@ -4,6 +4,14 @@ true $(NoWarn);CA1001;CA1859;DAP005 + + + PreserveNewest + + + PreserveNewest + + diff --git a/tests/KArtSell.Integration.Tests/OutboxInboxCrashRecoveryTests.cs b/tests/KArtSell.Integration.Tests/OutboxInboxCrashRecoveryTests.cs index d78a3ad7..63f7d46e 100644 --- a/tests/KArtSell.Integration.Tests/OutboxInboxCrashRecoveryTests.cs +++ b/tests/KArtSell.Integration.Tests/OutboxInboxCrashRecoveryTests.cs @@ -26,8 +26,7 @@ public sealed class OutboxInboxCrashRecoveryTests : IAsyncLifetime public OutboxInboxCrashRecoveryTests() { - _connectionString = Environment.GetEnvironmentVariable("KARTSELL_POSTGRES") - ?? "Host=localhost;Port=5432;Database=kartselldb;Username=kartsell;Password=kartsell4321@!"; + _connectionString = TestDatabaseConnection.GetConnectionString(); _dataSource = new NpgsqlDataSourceBuilder(_connectionString).Build(); _connectionFactory = new NpgsqlConnectionFactory(_dataSource); } diff --git a/tests/KArtSell.Integration.Tests/OutboxPollerJobTests.cs b/tests/KArtSell.Integration.Tests/OutboxPollerJobTests.cs index f8b09e55..738801b4 100644 --- a/tests/KArtSell.Integration.Tests/OutboxPollerJobTests.cs +++ b/tests/KArtSell.Integration.Tests/OutboxPollerJobTests.cs @@ -22,8 +22,7 @@ public sealed class OutboxPollerJobTests : IAsyncLifetime public OutboxPollerJobTests() { - _connectionString = Environment.GetEnvironmentVariable("KARTSELL_POSTGRES") - ?? "Host=localhost;Port=5432;Database=kartselldb;Username=kartsell;Password=kartsell4321@!"; + _connectionString = TestDatabaseConnection.GetConnectionString(); _dataSource = new NpgsqlDataSourceBuilder(_connectionString).Build(); _connectionFactory = new NpgsqlConnectionFactory(_dataSource); } diff --git a/tests/KArtSell.Integration.Tests/ShadowRunGate3Tests.cs b/tests/KArtSell.Integration.Tests/ShadowRunGate3Tests.cs index 0250e1d3..7dcf37ed 100644 --- a/tests/KArtSell.Integration.Tests/ShadowRunGate3Tests.cs +++ b/tests/KArtSell.Integration.Tests/ShadowRunGate3Tests.cs @@ -20,8 +20,7 @@ public sealed class ShadowRunGate3Tests : IAsyncLifetime public ShadowRunGate3Tests() { - _connectionString = Environment.GetEnvironmentVariable("KARTSELL_POSTGRES") - ?? "Host=localhost;Port=5432;Database=kartselldb;Username=kartsell;Password=kartsell4321@!"; + _connectionString = TestDatabaseConnection.GetConnectionString(); _dataSource = new NpgsqlDataSourceBuilder(_connectionString).Build(); _connectionFactory = new NpgsqlConnectionFactory(_dataSource); } @@ -86,8 +85,8 @@ public sealed class ShadowRunGate3Tests : IAsyncLifetime { RunId = runId, ModelId = modelId, - Start = new DateOnly(2024, 1, 2), - End = new DateOnly(2024, 8, 31), + Start = new DateTime(2024, 1, 2), + End = new DateTime(2024, 8, 31), Status = "EvaluationComplete", Now = now, Gates = validationGates, @@ -137,8 +136,8 @@ public sealed class ShadowRunGate3Tests : IAsyncLifetime { RunId = runId, ModelId = modelId, - Start = new DateOnly(2024, 1, 2), - End = new DateOnly(2024, 8, 31), + Start = new DateTime(2024, 1, 2), + End = new DateTime(2024, 8, 31), Status = "EvaluationComplete", Now = now, Gates = validationGates @@ -168,7 +167,7 @@ public sealed class ShadowRunGate3Tests : IAsyncLifetime // Arrange: Create shadow run that passes all gates var runId = Guid.NewGuid(); var modelId = Guid.NewGuid(); - var now = _clock.UtcNow.DateTime; + var now = MarketTime.SeoulDateTime(_clock.UtcNow); var validationGates = """ { @@ -190,8 +189,8 @@ public sealed class ShadowRunGate3Tests : IAsyncLifetime { RunId = runId, ModelId = modelId, - Start = new DateOnly(2024, 1, 2), - End = new DateOnly(2024, 8, 31), + Start = new DateTime(2024, 1, 2), + End = new DateTime(2024, 8, 31), Status = "EvaluationComplete", Now = now, Gates = validationGates @@ -227,7 +226,7 @@ public sealed class ShadowRunGate3Tests : IAsyncLifetime var runId = Guid.NewGuid(); var modelId = Guid.NewGuid(); var correlationId = Guid.NewGuid().ToString(); - var now = _clock.UtcNow.DateTime; + var now = MarketTime.SeoulDateTime(_clock.UtcNow); var validationGates = """{"all_gates_passed": true}"""; @@ -243,8 +242,8 @@ public sealed class ShadowRunGate3Tests : IAsyncLifetime { RunId = runId, ModelId = modelId, - Start = new DateOnly(2024, 1, 2), - End = new DateOnly(2024, 8, 31), + Start = new DateTime(2024, 1, 2), + End = new DateTime(2024, 8, 31), Status = "EvaluationComplete", Now = now, Gates = validationGates @@ -255,13 +254,13 @@ public sealed class ShadowRunGate3Tests : IAsyncLifetime await connection.ExecuteAsync(""" INSERT INTO building_blocks.outbox_message (message_id, event_type, schema_version, payload_json, correlation_id, occurred_at, payload_hash, published_at) - VALUES (@EventId, @EventType, 1, @Payload, @CorrelationId, @Now, @Hash, @Now) + VALUES (@EventId, @EventType, 1, CAST(@Payload AS jsonb), @CorrelationId, @Now, @Hash, @Now) """, new { EventId = eventId, EventType = "ShadowRunCompleted", - Payload = $$$"""{{"runId":"{runId}","modelId":"{modelId}"}}""", + Payload = $"{{\"runId\":\"{runId}\",\"modelId\":\"{modelId}\"}}", CorrelationId = correlationId, Now = now, Hash = "hash123" @@ -310,8 +309,8 @@ public sealed class ShadowRunGate3Tests : IAsyncLifetime { RunId = runId, ModelId = modelId, - Start = new DateOnly(2024, 1, 2), - End = new DateOnly(2024, 8, 31), + Start = new DateTime(2024, 1, 2), + End = new DateTime(2024, 8, 31), Status = "EvaluationComplete", Phase = phaseAnalysis }); @@ -364,8 +363,8 @@ public sealed class ShadowRunGate3Tests : IAsyncLifetime { RunId = runId, ModelId = modelId, - Start = new DateOnly(2024, 1, 2), - End = new DateOnly(2024, 8, 31), + Start = new DateTime(2024, 1, 2), + End = new DateTime(2024, 8, 31), Now = now, Gates = validationGates }); diff --git a/tests/KArtSell.Integration.Tests/TestDatabaseConnection.cs b/tests/KArtSell.Integration.Tests/TestDatabaseConnection.cs new file mode 100644 index 00000000..14ebc899 --- /dev/null +++ b/tests/KArtSell.Integration.Tests/TestDatabaseConnection.cs @@ -0,0 +1,24 @@ +using System.Text.Json; + +namespace KArtSell.Integration.Tests; + +internal static class TestDatabaseConnection +{ + public static string GetConnectionString() + { + var path = Path.Combine(AppContext.BaseDirectory, "appsettings.Development.json"); + if (!File.Exists(path)) + throw new InvalidOperationException($"Integration test settings are required: {path}"); + + using var document = JsonDocument.Parse(File.ReadAllText(path)); + if (!document.RootElement.TryGetProperty("ConnectionStrings", out var connectionStrings) || + !connectionStrings.TryGetProperty("Postgres", out var postgres) || + string.IsNullOrWhiteSpace(postgres.GetString())) + { + throw new InvalidOperationException( + "ConnectionStrings:Postgres is required in appsettings.Development.json."); + } + + return postgres.GetString()!; + } +}