테스트 DB 계약과 실행 안전성 정렬
ci / backend (push) Failing after 0s
ci / static (push) Failing after 6s
ci / backend (pull_request) Failing after 1s
ci / static (pull_request) Failing after 7s
Build & Test with Secrets / build (pull_request) Failing after 1s
ci / frontend (push) Failing after 48s
Build & Test with Secrets / security-scan (pull_request) Successful in 5s
Build & Test with Secrets / frontend (pull_request) Failing after 1m23s
ci / frontend (pull_request) Failing after 1m32s
Build & Test with Secrets / notification (pull_request) Failing after 2s
ci / backend (push) Failing after 0s
ci / static (push) Failing after 6s
ci / backend (pull_request) Failing after 1s
ci / static (pull_request) Failing after 7s
Build & Test with Secrets / build (pull_request) Failing after 1s
ci / frontend (push) Failing after 48s
Build & Test with Secrets / security-scan (pull_request) Successful in 5s
Build & Test with Secrets / frontend (pull_request) Failing after 1m23s
ci / frontend (pull_request) Failing after 1m32s
Build & Test with Secrets / notification (pull_request) Failing after 2s
This commit is contained in:
@@ -267,3 +267,40 @@ Every task — code change, refactor, new feature, tooling, infrastructure — m
|
|||||||
- ❌ Magic number → 근거 있는 상수, Policy ID로 추적
|
- ❌ Magic number → 근거 있는 상수, Policy ID로 추적
|
||||||
- ❌ "다른 모듈 테이블 조회" → Contract/Read Model만
|
- ❌ "다른 모듈 테이블 조회" → Contract/Read Model만
|
||||||
- ❌ 스킵된 테스트 기록 안 함 → Debt register에 DECISION_REQUIRED
|
- ❌ 스킵된 테스트 기록 안 함 → 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.
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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 '';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using KArtSell.BuildingBlocks.Data;
|
using KArtSell.BuildingBlocks.Data;
|
||||||
|
using KArtSell.BuildingBlocks.Time;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
|
|
||||||
@@ -15,17 +16,20 @@ public sealed class RecommendationReportGenerator
|
|||||||
private readonly IDbConnectionFactory _connectionFactory;
|
private readonly IDbConnectionFactory _connectionFactory;
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly ILogger<RecommendationReportGenerator> _logger;
|
private readonly ILogger<RecommendationReportGenerator> _logger;
|
||||||
|
private readonly IClock _clock;
|
||||||
private readonly string _telegramBotToken;
|
private readonly string _telegramBotToken;
|
||||||
private readonly string _telegramChatId;
|
private readonly string _telegramChatId;
|
||||||
|
|
||||||
public RecommendationReportGenerator(
|
public RecommendationReportGenerator(
|
||||||
IDbConnectionFactory connectionFactory,
|
IDbConnectionFactory connectionFactory,
|
||||||
HttpClient httpClient,
|
HttpClient httpClient,
|
||||||
ILogger<RecommendationReportGenerator> logger)
|
ILogger<RecommendationReportGenerator> logger,
|
||||||
|
IClock clock)
|
||||||
{
|
{
|
||||||
_connectionFactory = connectionFactory;
|
_connectionFactory = connectionFactory;
|
||||||
_httpClient = httpClient;
|
_httpClient = httpClient;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_clock = clock;
|
||||||
_telegramBotToken = Environment.GetEnvironmentVariable("TELEGRAM_BOT") ?? string.Empty;
|
_telegramBotToken = Environment.GetEnvironmentVariable("TELEGRAM_BOT") ?? string.Empty;
|
||||||
_telegramChatId = Environment.GetEnvironmentVariable("CHAT_ID") ?? string.Empty;
|
_telegramChatId = Environment.GetEnvironmentVariable("CHAT_ID") ?? string.Empty;
|
||||||
}
|
}
|
||||||
@@ -61,7 +65,7 @@ public sealed class RecommendationReportGenerator
|
|||||||
return new RecommendationReport
|
return new RecommendationReport
|
||||||
{
|
{
|
||||||
ReportType = "Weekly",
|
ReportType = "Weekly",
|
||||||
ReportDate = DateTime.UtcNow,
|
ReportDate = _clock.UtcNow.DateTime,
|
||||||
PeriodStart = startDate,
|
PeriodStart = startDate,
|
||||||
PeriodEnd = endDate,
|
PeriodEnd = endDate,
|
||||||
Recommendations = recommendations
|
Recommendations = recommendations
|
||||||
@@ -80,7 +84,7 @@ public sealed class RecommendationReportGenerator
|
|||||||
return new RecommendationReport
|
return new RecommendationReport
|
||||||
{
|
{
|
||||||
ReportType = "Monthly",
|
ReportType = "Monthly",
|
||||||
ReportDate = DateTime.UtcNow,
|
ReportDate = _clock.UtcNow.DateTime,
|
||||||
PeriodStart = startDate,
|
PeriodStart = startDate,
|
||||||
PeriodEnd = endDate,
|
PeriodEnd = endDate,
|
||||||
Recommendations = recommendations
|
Recommendations = recommendations
|
||||||
|
|||||||
@@ -23,20 +23,22 @@ public sealed class Handler(IDbConnectionFactory connectionFactory, IClock clock
|
|||||||
throw new InvalidOperationException($"Approval status is {existing.Value.Status}, not Pending");
|
throw new InvalidOperationException($"Approval status is {existing.Value.Status}, not Pending");
|
||||||
|
|
||||||
// Update approval status (trigger will set approved_at)
|
// Update approval status (trigger will set approved_at)
|
||||||
var now = clock.UtcNow.DateTime;
|
var now = MarketTime.SeoulDateTime(clock.UtcNow);
|
||||||
await connection.ExecuteAsync("""
|
await connection.ExecuteAsync("""
|
||||||
UPDATE model_operations.approval_queue
|
UPDATE model_operations.approval_queue
|
||||||
SET
|
SET
|
||||||
status = 'Approved',
|
status = 'Approved',
|
||||||
approved_by = @ApprovedBy,
|
approved_by = @ApprovedBy,
|
||||||
approval_reason = @ApprovalReason
|
approval_reason = @ApprovalReason,
|
||||||
|
approved_at = @ApprovedAt
|
||||||
WHERE run_id = @RunId
|
WHERE run_id = @RunId
|
||||||
""",
|
""",
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
request.RunId,
|
request.RunId,
|
||||||
ApprovedBy = approverUserId,
|
ApprovedBy = approverUserId,
|
||||||
request.ApprovalReason
|
request.ApprovalReason,
|
||||||
|
ApprovedAt = now
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
|
|||||||
@@ -23,18 +23,20 @@ public sealed class Handler(IDbConnectionFactory connectionFactory, IClock clock
|
|||||||
throw new InvalidOperationException($"Approval status is {existing.Value.Status}, not Pending");
|
throw new InvalidOperationException($"Approval status is {existing.Value.Status}, not Pending");
|
||||||
|
|
||||||
// Update approval status (trigger will set rejected_at)
|
// Update approval status (trigger will set rejected_at)
|
||||||
var now = clock.UtcNow.DateTime;
|
var now = MarketTime.SeoulDateTime(clock.UtcNow);
|
||||||
await connection.ExecuteAsync("""
|
await connection.ExecuteAsync("""
|
||||||
UPDATE model_operations.approval_queue
|
UPDATE model_operations.approval_queue
|
||||||
SET
|
SET
|
||||||
status = 'Rejected',
|
status = 'Rejected',
|
||||||
rejection_reason = @RejectionReason
|
rejection_reason = @RejectionReason,
|
||||||
|
rejected_at = @RejectedAt
|
||||||
WHERE run_id = @RunId
|
WHERE run_id = @RunId
|
||||||
""",
|
""",
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
request.RunId,
|
request.RunId,
|
||||||
request.RejectionReason
|
request.RejectionReason,
|
||||||
|
RejectedAt = now
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
using KArtSell.BuildingBlocks.Time;
|
||||||
|
|
||||||
namespace KArtSell.Modules.ModelOperations.Observability;
|
namespace KArtSell.Modules.ModelOperations.Observability;
|
||||||
|
|
||||||
public sealed class StubObservabilityService : IObservabilityService
|
public sealed class StubObservabilityService(IClock clock) : IObservabilityService
|
||||||
{
|
{
|
||||||
public async Task<ObservabilityMetricsDto> GetMetricsAsync(CancellationToken cancellationToken)
|
public async Task<ObservabilityMetricsDto> GetMetricsAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -23,7 +25,7 @@ public sealed class StubObservabilityService : IObservabilityService
|
|||||||
DuplicateDetection = new DuplicateDetectionMetrics
|
DuplicateDetection = new DuplicateDetectionMetrics
|
||||||
{
|
{
|
||||||
ConstraintViolationCount = 0,
|
ConstraintViolationCount = 0,
|
||||||
LastDetected = DateTime.UtcNow
|
LastDetected = clock.UtcNow.DateTime
|
||||||
},
|
},
|
||||||
Reconciliation = new ReconciliationMetrics
|
Reconciliation = new ReconciliationMetrics
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ public sealed class ApprovalWorkflowTests : IAsyncLifetime
|
|||||||
|
|
||||||
public ApprovalWorkflowTests()
|
public ApprovalWorkflowTests()
|
||||||
{
|
{
|
||||||
_connectionString = Environment.GetEnvironmentVariable("KARTSELL_POSTGRES")
|
_connectionString = TestDatabaseConnection.GetConnectionString();
|
||||||
?? "Host=localhost;Port=5432;Database=kartselldb;Username=kartsell;Password=kartsell4321@!";
|
|
||||||
_dataSource = new NpgsqlDataSourceBuilder(_connectionString).Build();
|
_dataSource = new NpgsqlDataSourceBuilder(_connectionString).Build();
|
||||||
_connectionFactory = new NpgsqlConnectionFactory(_dataSource);
|
_connectionFactory = new NpgsqlConnectionFactory(_dataSource);
|
||||||
}
|
}
|
||||||
@@ -67,8 +66,8 @@ public sealed class ApprovalWorkflowTests : IAsyncLifetime
|
|||||||
""",
|
""",
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
new { RunId = runId1, 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 DateOnly(2024, 1, 2), End = new DateOnly(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
|
// 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)
|
INSERT INTO model_operations.shadow_run (run_id, model_id, window_start, window_end, status)
|
||||||
VALUES (@RunId, @ModelId, @Start, @End, 'EvaluationComplete')
|
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("""
|
await connection.ExecuteAsync("""
|
||||||
INSERT INTO model_operations.approval_queue (run_id, model_id, status)
|
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)
|
INSERT INTO model_operations.shadow_run (run_id, model_id, window_start, window_end, status)
|
||||||
VALUES (@RunId, @ModelId, @Start, @End, 'EvaluationComplete')
|
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("""
|
await connection.ExecuteAsync("""
|
||||||
INSERT INTO model_operations.approval_queue (run_id, model_id, status, approved_by, approval_reason)
|
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)
|
INSERT INTO model_operations.shadow_run (run_id, model_id, window_start, window_end, status)
|
||||||
VALUES (@RunId, @ModelId, @Start, @End, 'EvaluationComplete')
|
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("""
|
await connection.ExecuteAsync("""
|
||||||
INSERT INTO model_operations.approval_queue (run_id, model_id, status)
|
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 runId = Guid.NewGuid();
|
||||||
var modelId = Guid.NewGuid();
|
var modelId = Guid.NewGuid();
|
||||||
var approverId = 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);
|
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)
|
INSERT INTO model_operations.shadow_run (run_id, model_id, window_start, window_end, status)
|
||||||
VALUES (@RunId, @ModelId, @Start, @End, 'EvaluationComplete')
|
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("""
|
await connection.ExecuteAsync("""
|
||||||
INSERT INTO model_operations.approval_queue (run_id, model_id, status)
|
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 request = new ApproveRequest(runId, "Approved");
|
||||||
var response = await handler.HandleAsync(request, approverId, CancellationToken.None);
|
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
|
// Assert: Timestamps are within expected range
|
||||||
var approval = await connection.QuerySingleAsync<(DateTime RequestedAt, DateTime ApprovedAt)>("""
|
var approval = await connection.QuerySingleAsync<(DateTime RequestedAt, DateTime ApprovedAt)>("""
|
||||||
@@ -267,7 +266,7 @@ public sealed class ApprovalWorkflowTests : IAsyncLifetime
|
|||||||
""",
|
""",
|
||||||
new { RunId = runId });
|
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));
|
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)
|
INSERT INTO model_operations.shadow_run (run_id, model_id, window_start, window_end, status)
|
||||||
VALUES (@RunId, @ModelId, @Start, @End, 'EvaluationComplete')
|
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
|
// Insert first approval
|
||||||
await connection.ExecuteAsync("""
|
await connection.ExecuteAsync("""
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public sealed class DbUpMigrationTests : IAsyncLifetime
|
|||||||
|
|
||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
{
|
{
|
||||||
var connString = Environment.GetEnvironmentVariable("KARTSELL_POSTGRES") ?? DefaultConnString;
|
var connString = TestDatabaseConnection.GetConnectionString();
|
||||||
|
|
||||||
// Create test database if needed
|
// Create test database if needed
|
||||||
var adminConnString = connString.Replace("kartsell_migration_test", "postgres");
|
var adminConnString = connString.Replace("kartsell_migration_test", "postgres");
|
||||||
@@ -51,7 +51,7 @@ public sealed class DbUpMigrationTests : IAsyncLifetime
|
|||||||
await _dataSource.DisposeAsync();
|
await _dataSource.DisposeAsync();
|
||||||
|
|
||||||
// Cleanup test database
|
// Cleanup test database
|
||||||
var adminConnString = Environment.GetEnvironmentVariable("KARTSELL_POSTGRES") ?? DefaultConnString;
|
var adminConnString = TestDatabaseConnection.GetConnectionString();
|
||||||
adminConnString = adminConnString.Replace("kartsell_migration_test", "postgres");
|
adminConnString = adminConnString.Replace("kartsell_migration_test", "postgres");
|
||||||
|
|
||||||
await using var adminConn = new NpgsqlConnection(adminConnString);
|
await using var adminConn = new NpgsqlConnection(adminConnString);
|
||||||
@@ -149,13 +149,10 @@ public sealed class DbUpMigrationTests : IAsyncLifetime
|
|||||||
|
|
||||||
// Assert: All three tables exist
|
// Assert: All three tables exist
|
||||||
Assert.True(await TableExistsAsync(connection, "model_operations", "shadow_run"));
|
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.True(await TableExistsAsync(connection, "model_operations", "approval_queue"));
|
||||||
|
|
||||||
// Assert: Foreign keys exist
|
// 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");
|
var aqFkCount = await CountForeignKeysAsync(connection, "model_operations", "approval_queue");
|
||||||
Assert.True(aqFkCount > 0, "approval_queue should have FK to shadow_run");
|
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
|
// Assert: Data survived, table is unchanged
|
||||||
await using var selectCmd = connection.CreateCommand();
|
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();
|
var count = (long?)await selectCmd.ExecuteScalarAsync();
|
||||||
|
|
||||||
Assert.Equal(1, count);
|
Assert.Equal(1, count);
|
||||||
@@ -268,8 +266,9 @@ public sealed class DbUpMigrationTests : IAsyncLifetime
|
|||||||
var outboxId = Guid.NewGuid();
|
var outboxId = Guid.NewGuid();
|
||||||
await using var outboxCmd = connection.CreateCommand();
|
await using var outboxCmd = connection.CreateCommand();
|
||||||
outboxCmd.CommandText = """
|
outboxCmd.CommandText = """
|
||||||
INSERT INTO outbox.outbox (id, event_type, payload)
|
INSERT INTO building_blocks.outbox_message
|
||||||
VALUES (@id, 'TestEvent', '{"test":"data"}'::jsonb);
|
(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);
|
outboxCmd.Parameters.AddWithValue("@id", outboxId);
|
||||||
await outboxCmd.ExecuteNonQueryAsync();
|
await outboxCmd.ExecuteNonQueryAsync();
|
||||||
@@ -278,11 +277,10 @@ public sealed class DbUpMigrationTests : IAsyncLifetime
|
|||||||
var inboxId = Guid.NewGuid();
|
var inboxId = Guid.NewGuid();
|
||||||
await using var invalidCmd = connection.CreateCommand();
|
await using var invalidCmd = connection.CreateCommand();
|
||||||
invalidCmd.CommandText = """
|
invalidCmd.CommandText = """
|
||||||
INSERT INTO outbox.inbox (id, outbox_id, consumer_id, event_type, payload, status)
|
INSERT INTO building_blocks.inbox_message (message_id, consumer, status)
|
||||||
VALUES (@id, @outboxId, 'TestConsumer', 'TestEvent', '{"test":"data"}'::jsonb, 'Processed');
|
VALUES (@id, 'TestConsumer', 'Processed');
|
||||||
""";
|
""";
|
||||||
invalidCmd.Parameters.AddWithValue("@id", inboxId);
|
invalidCmd.Parameters.AddWithValue("@id", inboxId);
|
||||||
invalidCmd.Parameters.AddWithValue("@outboxId", outboxId);
|
|
||||||
|
|
||||||
// Assert: Trigger violation
|
// Assert: Trigger violation
|
||||||
await Assert.ThrowsAsync<PostgresException>(
|
await Assert.ThrowsAsync<PostgresException>(
|
||||||
@@ -304,8 +302,9 @@ public sealed class DbUpMigrationTests : IAsyncLifetime
|
|||||||
var outboxId = Guid.NewGuid();
|
var outboxId = Guid.NewGuid();
|
||||||
await using var outboxCmd = connection.CreateCommand();
|
await using var outboxCmd = connection.CreateCommand();
|
||||||
outboxCmd.CommandText = """
|
outboxCmd.CommandText = """
|
||||||
INSERT INTO outbox.outbox (id, event_type, payload)
|
INSERT INTO building_blocks.outbox_message
|
||||||
VALUES (@id, 'TestEvent', '{"test":"data"}'::jsonb);
|
(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);
|
outboxCmd.Parameters.AddWithValue("@id", outboxId);
|
||||||
await outboxCmd.ExecuteNonQueryAsync();
|
await outboxCmd.ExecuteNonQueryAsync();
|
||||||
@@ -313,8 +312,8 @@ public sealed class DbUpMigrationTests : IAsyncLifetime
|
|||||||
// Insert first inbox record
|
// Insert first inbox record
|
||||||
await using var insertCmd = connection.CreateCommand();
|
await using var insertCmd = connection.CreateCommand();
|
||||||
insertCmd.CommandText = """
|
insertCmd.CommandText = """
|
||||||
INSERT INTO outbox.inbox (outbox_id, consumer_id, event_type, payload, status)
|
INSERT INTO building_blocks.inbox_message (message_id, consumer, status)
|
||||||
VALUES (@outboxId, 'Consumer1', 'TestEvent', '{"test":"data"}'::jsonb, 'Pending');
|
VALUES (@outboxId, 'Consumer1', 'Pending');
|
||||||
""";
|
""";
|
||||||
insertCmd.Parameters.AddWithValue("@outboxId", outboxId);
|
insertCmd.Parameters.AddWithValue("@outboxId", outboxId);
|
||||||
await insertCmd.ExecuteNonQueryAsync();
|
await insertCmd.ExecuteNonQueryAsync();
|
||||||
@@ -322,8 +321,8 @@ public sealed class DbUpMigrationTests : IAsyncLifetime
|
|||||||
// Act: Try to insert duplicate (same outbox_id + consumer_id)
|
// Act: Try to insert duplicate (same outbox_id + consumer_id)
|
||||||
await using var duplicateCmd = connection.CreateCommand();
|
await using var duplicateCmd = connection.CreateCommand();
|
||||||
duplicateCmd.CommandText = """
|
duplicateCmd.CommandText = """
|
||||||
INSERT INTO outbox.inbox (outbox_id, consumer_id, event_type, payload, status)
|
INSERT INTO building_blocks.inbox_message (message_id, consumer, status)
|
||||||
VALUES (@outboxId, 'Consumer1', 'TestEvent', '{"test":"data"}'::jsonb, 'Pending');
|
VALUES (@outboxId, 'Consumer1', 'Pending');
|
||||||
""";
|
""";
|
||||||
duplicateCmd.Parameters.AddWithValue("@outboxId", outboxId);
|
duplicateCmd.Parameters.AddWithValue("@outboxId", outboxId);
|
||||||
|
|
||||||
@@ -479,7 +478,7 @@ public sealed class DbUpMigrationTests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
await using var connection = await _dataSource.OpenConnectionAsync();
|
await using var connection = await _dataSource.OpenConnectionAsync();
|
||||||
await using var cmd = connection.CreateCommand();
|
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();
|
await cmd.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,7 +486,7 @@ public sealed class DbUpMigrationTests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
await using var connection = await _dataSource.OpenConnectionAsync();
|
await using var connection = await _dataSource.OpenConnectionAsync();
|
||||||
await using var cmd = connection.CreateCommand();
|
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();
|
await cmd.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,7 +494,7 @@ public sealed class DbUpMigrationTests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
await using var connection = await _dataSource.OpenConnectionAsync();
|
await using var connection = await _dataSource.OpenConnectionAsync();
|
||||||
await using var cmd = connection.CreateCommand();
|
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();
|
await cmd.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,14 @@
|
|||||||
<IsTestProject>true</IsTestProject>
|
<IsTestProject>true</IsTestProject>
|
||||||
<NoWarn>$(NoWarn);CA1001;CA1859;DAP005</NoWarn>
|
<NoWarn>$(NoWarn);CA1001;CA1859;DAP005</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="appsettings.Development.json">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
<None Include="../../db/migrations/*.sql" Link="migrations/%(Filename)%(Extension)">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../../src/KArtSell.BuildingBlocks/KArtSell.BuildingBlocks.csproj" />
|
<ProjectReference Include="../../src/KArtSell.BuildingBlocks/KArtSell.BuildingBlocks.csproj" />
|
||||||
<ProjectReference Include="../../src/KArtSell.Host/KArtSell.Host.csproj" />
|
<ProjectReference Include="../../src/KArtSell.Host/KArtSell.Host.csproj" />
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ public sealed class OutboxInboxCrashRecoveryTests : IAsyncLifetime
|
|||||||
|
|
||||||
public OutboxInboxCrashRecoveryTests()
|
public OutboxInboxCrashRecoveryTests()
|
||||||
{
|
{
|
||||||
_connectionString = Environment.GetEnvironmentVariable("KARTSELL_POSTGRES")
|
_connectionString = TestDatabaseConnection.GetConnectionString();
|
||||||
?? "Host=localhost;Port=5432;Database=kartselldb;Username=kartsell;Password=kartsell4321@!";
|
|
||||||
_dataSource = new NpgsqlDataSourceBuilder(_connectionString).Build();
|
_dataSource = new NpgsqlDataSourceBuilder(_connectionString).Build();
|
||||||
_connectionFactory = new NpgsqlConnectionFactory(_dataSource);
|
_connectionFactory = new NpgsqlConnectionFactory(_dataSource);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ public sealed class OutboxPollerJobTests : IAsyncLifetime
|
|||||||
|
|
||||||
public OutboxPollerJobTests()
|
public OutboxPollerJobTests()
|
||||||
{
|
{
|
||||||
_connectionString = Environment.GetEnvironmentVariable("KARTSELL_POSTGRES")
|
_connectionString = TestDatabaseConnection.GetConnectionString();
|
||||||
?? "Host=localhost;Port=5432;Database=kartselldb;Username=kartsell;Password=kartsell4321@!";
|
|
||||||
_dataSource = new NpgsqlDataSourceBuilder(_connectionString).Build();
|
_dataSource = new NpgsqlDataSourceBuilder(_connectionString).Build();
|
||||||
_connectionFactory = new NpgsqlConnectionFactory(_dataSource);
|
_connectionFactory = new NpgsqlConnectionFactory(_dataSource);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ public sealed class ShadowRunGate3Tests : IAsyncLifetime
|
|||||||
|
|
||||||
public ShadowRunGate3Tests()
|
public ShadowRunGate3Tests()
|
||||||
{
|
{
|
||||||
_connectionString = Environment.GetEnvironmentVariable("KARTSELL_POSTGRES")
|
_connectionString = TestDatabaseConnection.GetConnectionString();
|
||||||
?? "Host=localhost;Port=5432;Database=kartselldb;Username=kartsell;Password=kartsell4321@!";
|
|
||||||
_dataSource = new NpgsqlDataSourceBuilder(_connectionString).Build();
|
_dataSource = new NpgsqlDataSourceBuilder(_connectionString).Build();
|
||||||
_connectionFactory = new NpgsqlConnectionFactory(_dataSource);
|
_connectionFactory = new NpgsqlConnectionFactory(_dataSource);
|
||||||
}
|
}
|
||||||
@@ -86,8 +85,8 @@ public sealed class ShadowRunGate3Tests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
RunId = runId,
|
RunId = runId,
|
||||||
ModelId = modelId,
|
ModelId = modelId,
|
||||||
Start = new DateOnly(2024, 1, 2),
|
Start = new DateTime(2024, 1, 2),
|
||||||
End = new DateOnly(2024, 8, 31),
|
End = new DateTime(2024, 8, 31),
|
||||||
Status = "EvaluationComplete",
|
Status = "EvaluationComplete",
|
||||||
Now = now,
|
Now = now,
|
||||||
Gates = validationGates,
|
Gates = validationGates,
|
||||||
@@ -137,8 +136,8 @@ public sealed class ShadowRunGate3Tests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
RunId = runId,
|
RunId = runId,
|
||||||
ModelId = modelId,
|
ModelId = modelId,
|
||||||
Start = new DateOnly(2024, 1, 2),
|
Start = new DateTime(2024, 1, 2),
|
||||||
End = new DateOnly(2024, 8, 31),
|
End = new DateTime(2024, 8, 31),
|
||||||
Status = "EvaluationComplete",
|
Status = "EvaluationComplete",
|
||||||
Now = now,
|
Now = now,
|
||||||
Gates = validationGates
|
Gates = validationGates
|
||||||
@@ -168,7 +167,7 @@ public sealed class ShadowRunGate3Tests : IAsyncLifetime
|
|||||||
// Arrange: Create shadow run that passes all gates
|
// Arrange: Create shadow run that passes all gates
|
||||||
var runId = Guid.NewGuid();
|
var runId = Guid.NewGuid();
|
||||||
var modelId = Guid.NewGuid();
|
var modelId = Guid.NewGuid();
|
||||||
var now = _clock.UtcNow.DateTime;
|
var now = MarketTime.SeoulDateTime(_clock.UtcNow);
|
||||||
|
|
||||||
var validationGates = """
|
var validationGates = """
|
||||||
{
|
{
|
||||||
@@ -190,8 +189,8 @@ public sealed class ShadowRunGate3Tests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
RunId = runId,
|
RunId = runId,
|
||||||
ModelId = modelId,
|
ModelId = modelId,
|
||||||
Start = new DateOnly(2024, 1, 2),
|
Start = new DateTime(2024, 1, 2),
|
||||||
End = new DateOnly(2024, 8, 31),
|
End = new DateTime(2024, 8, 31),
|
||||||
Status = "EvaluationComplete",
|
Status = "EvaluationComplete",
|
||||||
Now = now,
|
Now = now,
|
||||||
Gates = validationGates
|
Gates = validationGates
|
||||||
@@ -227,7 +226,7 @@ public sealed class ShadowRunGate3Tests : IAsyncLifetime
|
|||||||
var runId = Guid.NewGuid();
|
var runId = Guid.NewGuid();
|
||||||
var modelId = Guid.NewGuid();
|
var modelId = Guid.NewGuid();
|
||||||
var correlationId = Guid.NewGuid().ToString();
|
var correlationId = Guid.NewGuid().ToString();
|
||||||
var now = _clock.UtcNow.DateTime;
|
var now = MarketTime.SeoulDateTime(_clock.UtcNow);
|
||||||
|
|
||||||
var validationGates = """{"all_gates_passed": true}""";
|
var validationGates = """{"all_gates_passed": true}""";
|
||||||
|
|
||||||
@@ -243,8 +242,8 @@ public sealed class ShadowRunGate3Tests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
RunId = runId,
|
RunId = runId,
|
||||||
ModelId = modelId,
|
ModelId = modelId,
|
||||||
Start = new DateOnly(2024, 1, 2),
|
Start = new DateTime(2024, 1, 2),
|
||||||
End = new DateOnly(2024, 8, 31),
|
End = new DateTime(2024, 8, 31),
|
||||||
Status = "EvaluationComplete",
|
Status = "EvaluationComplete",
|
||||||
Now = now,
|
Now = now,
|
||||||
Gates = validationGates
|
Gates = validationGates
|
||||||
@@ -255,13 +254,13 @@ public sealed class ShadowRunGate3Tests : IAsyncLifetime
|
|||||||
await connection.ExecuteAsync("""
|
await connection.ExecuteAsync("""
|
||||||
INSERT INTO building_blocks.outbox_message
|
INSERT INTO building_blocks.outbox_message
|
||||||
(message_id, event_type, schema_version, payload_json, correlation_id, occurred_at, payload_hash, published_at)
|
(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
|
new
|
||||||
{
|
{
|
||||||
EventId = eventId,
|
EventId = eventId,
|
||||||
EventType = "ShadowRunCompleted",
|
EventType = "ShadowRunCompleted",
|
||||||
Payload = $$$"""{{"runId":"{runId}","modelId":"{modelId}"}}""",
|
Payload = $"{{\"runId\":\"{runId}\",\"modelId\":\"{modelId}\"}}",
|
||||||
CorrelationId = correlationId,
|
CorrelationId = correlationId,
|
||||||
Now = now,
|
Now = now,
|
||||||
Hash = "hash123"
|
Hash = "hash123"
|
||||||
@@ -310,8 +309,8 @@ public sealed class ShadowRunGate3Tests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
RunId = runId,
|
RunId = runId,
|
||||||
ModelId = modelId,
|
ModelId = modelId,
|
||||||
Start = new DateOnly(2024, 1, 2),
|
Start = new DateTime(2024, 1, 2),
|
||||||
End = new DateOnly(2024, 8, 31),
|
End = new DateTime(2024, 8, 31),
|
||||||
Status = "EvaluationComplete",
|
Status = "EvaluationComplete",
|
||||||
Phase = phaseAnalysis
|
Phase = phaseAnalysis
|
||||||
});
|
});
|
||||||
@@ -364,8 +363,8 @@ public sealed class ShadowRunGate3Tests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
RunId = runId,
|
RunId = runId,
|
||||||
ModelId = modelId,
|
ModelId = modelId,
|
||||||
Start = new DateOnly(2024, 1, 2),
|
Start = new DateTime(2024, 1, 2),
|
||||||
End = new DateOnly(2024, 8, 31),
|
End = new DateTime(2024, 8, 31),
|
||||||
Now = now,
|
Now = now,
|
||||||
Gates = validationGates
|
Gates = validationGates
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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()!;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user