Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 63a95c9242 | |||
| f0a945ab96 |
@@ -7,7 +7,7 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
@@ -107,6 +107,11 @@ jobs:
|
||||
# Cleanup
|
||||
rm /tmp/deploy_key.pem
|
||||
|
||||
- name: Tag release version
|
||||
run: |
|
||||
git tag "v${VITE_APP_VERSION}"
|
||||
git push origin "v${VITE_APP_VERSION}"
|
||||
|
||||
notify:
|
||||
if: always()
|
||||
needs: deploy
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.env.local
|
||||
frontend/test-results/
|
||||
.playwright/
|
||||
TestResults/
|
||||
*.user
|
||||
@@ -13,3 +14,4 @@ __pycache__/
|
||||
*.log
|
||||
host*.log
|
||||
artifacts/
|
||||
publish-verify/
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
-- Migration 0036: Approval workflow schema (VS-03)
|
||||
-- Creates tables for model activation approval gates with maker-checker separation
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'approval_proposals' AND table_schema = 'model_operations')
|
||||
BEGIN
|
||||
CREATE TABLE model_operations.approval_proposals (
|
||||
id UUID PRIMARY KEY,
|
||||
model_id UUID NOT NULL REFERENCES model_operations.models(id),
|
||||
status VARCHAR(50) NOT NULL,
|
||||
created_by VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
justification TEXT NOT NULL,
|
||||
effective_at DATE NOT NULL,
|
||||
proposed_at TIMESTAMPTZ,
|
||||
approved_by VARCHAR(255),
|
||||
approved_at TIMESTAMPTZ,
|
||||
approval_notes TEXT,
|
||||
activated_by VARCHAR(255),
|
||||
activated_at TIMESTAMPTZ,
|
||||
published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
revision INT NOT NULL DEFAULT 1,
|
||||
correlation_id UUID NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX ix_approval_proposals_model_id ON model_operations.approval_proposals(model_id);
|
||||
CREATE INDEX ix_approval_proposals_status ON model_operations.approval_proposals(status);
|
||||
CREATE INDEX ix_approval_proposals_created_by ON model_operations.approval_proposals(created_by);
|
||||
CREATE INDEX ix_approval_proposals_approved_by ON model_operations.approval_proposals(approved_by);
|
||||
CREATE INDEX ix_approval_proposals_correlation_id ON model_operations.approval_proposals(correlation_id);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'approval_evidence' AND table_schema = 'model_operations')
|
||||
BEGIN
|
||||
CREATE TABLE model_operations.approval_evidence (
|
||||
id UUID PRIMARY KEY,
|
||||
approval_proposal_id UUID NOT NULL REFERENCES model_operations.approval_proposals(id),
|
||||
evidence_type VARCHAR(50) NOT NULL,
|
||||
evidence_url TEXT NOT NULL,
|
||||
reviewer_comment TEXT,
|
||||
published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
correlation_id UUID NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX ix_approval_evidence_proposal_id ON model_operations.approval_evidence(approval_proposal_id);
|
||||
CREATE INDEX ix_approval_evidence_type ON model_operations.approval_evidence(evidence_type);
|
||||
CREATE INDEX ix_approval_evidence_correlation_id ON model_operations.approval_evidence(correlation_id);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'approval_events' AND table_schema = 'model_operations')
|
||||
BEGIN
|
||||
CREATE TABLE model_operations.approval_events (
|
||||
id UUID PRIMARY KEY,
|
||||
approval_proposal_id UUID NOT NULL REFERENCES model_operations.approval_proposals(id),
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
actor_email VARCHAR(255) NOT NULL,
|
||||
event_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
details JSONB,
|
||||
published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
correlation_id UUID NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX ix_approval_events_proposal_id ON model_operations.approval_events(approval_proposal_id);
|
||||
CREATE INDEX ix_approval_events_type ON model_operations.approval_events(event_type);
|
||||
CREATE INDEX ix_approval_events_correlation_id ON model_operations.approval_events(correlation_id);
|
||||
END;
|
||||
@@ -6,7 +6,7 @@
|
||||
- Requirement: `REQ-DB-001`
|
||||
- Gate: `G0`
|
||||
- Source: `docs/CURRENT/WBS_EXECUTION_PROCEDURES.md`, `db/migrations/*.sql`, DbUp integration tests
|
||||
- Assumption: the configured integration database is the approved non-production test database `kartselldb_test`.
|
||||
- Assumption: `kartselldb_test` is the approved credential/source database and `kartsell_migration_test` is the isolated destructive migration-rehearsal target.
|
||||
- Unknown: production rehearsal and DBA sign-off were not performed.
|
||||
- Decision Required: none for this test-database rehearsal; production approval remains required.
|
||||
|
||||
@@ -24,7 +24,7 @@ PASS: 6/6, duration 28ms
|
||||
TRX: tests/KArtSell.Integration.Tests/TestResults/kjh20_KIMJAEHYUN-OFFI_2026-08-06_14_07_41_net10.0.trx
|
||||
```
|
||||
|
||||
The evidence covers the repository's fresh/upgrade/re-run/recovery and checksum protection test cases. No production database, automatic order, KIS submission, or migration mutation outside the approved test fixture was used.
|
||||
The evidence covers the repository's fresh/upgrade/re-run/recovery and checksum protection test cases. No production database, automatic order, KIS submission, or migration mutation outside the isolated `kartsell_migration_test` fixture was used. The configured `kartselldb_test` database was not dropped or recreated.
|
||||
|
||||
## Completion boundary
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ WBS_ID,Sprint,Slice_ID,Task,Status,Completion_Date,Evidence_Link,Owner,Notes
|
||||
AEG-X-001,S0,Cross,Version Coverage Matrix 고도화,COMPLETED,2026-08-04,docs/contracts/platform/VERSION_COVERAGE_MATRIX.md,PM/Architect,"✅ Version matrix: v10/v12/v12.1 compatibility (Retained/Improved/Superseded 100%), Supersession registry, Breaking change assessment, Migration roadmap"
|
||||
AEG-X-002,S0,Cross,global.json 고도화,COMPLETED,2026-08-04,.gitea/workflows/ci.yml (dotnet/pnpm restore/build/test),DevOps,"✅ CI pipeline validates: dotnet restore/build/test (Release config), pnpm frozen install/build/e2e, PostgreSQL 17 health checks, Log output to .gitea/workflows/ci.yml"
|
||||
AEG-X-003,S0,Cross,Architecture tests 고도화,COMPLETED,2026-08-04,tests/KArtSell.ArchitectureTests/RepositoryRulesTests.cs (6 tests PASSING),Architect/QA,"✅ Architecture rules enforced: (1) No prohibited patterns, (2) Domain isolation from infrastructure, (3) SQL validation (no SELECT *, schema-qualified), (4) Endpoint authorization (Roles/Policies), (5) No placeholder files, (6) No duplicate aggregate IDs. All 6 tests PASS."
|
||||
AEG-X-004,S0,Cross,DbUp 복구 rehearsal 고도화,COMPLETED,2026-08-06,"docs/CURRENT/AEG-X-004_DBUP_EVIDENCE.md; docs/CURRENT/AEG-X-004_STATUS_CONTRACT_SLICE.md; db/migrations/0032_shadow_run_queued_status_contract.sql; tests/KArtSell.Integration.Tests/DbUpMigrationTests.cs; tests/KArtSell.Integration.Tests/DbUpRecoveryTests.cs",DBA/BE,"✅ Queued status contract correction applied as append-only 0032; targeted 1/1, DbUpMigrationTests 12/12, DbUpRecoveryTests 6/6 passed against approved test database. Production migration/DBA approval and Phase 1 requeue remain unclaimed."
|
||||
AEG-X-004,S0,Cross,DbUp 복구 rehearsal 고도화,COMPLETED,2026-08-06,"docs/CURRENT/AEG-X-004_DBUP_EVIDENCE.md; docs/CURRENT/AEG-X-004_STATUS_CONTRACT_SLICE.md; db/migrations/0032_shadow_run_queued_status_contract.sql; tests/KArtSell.Integration.Tests/DbUpMigrationTests.cs; tests/KArtSell.Integration.Tests/DbUpRecoveryTests.cs; evidence/AEG-X-004/0032-isolated-migration.trx",DBA/BE,"✅ Queued status contract applied as append-only 0032; isolated kartsell_migration_test rehearsal targeted 1/1 and recovery 6/6 passed. kartselldb_test was not reset. Production migration/DBA approval and Phase 1 requeue remain unclaimed."
|
||||
AEG-X-005,S0,Cross,Security auth 고도화,COMPLETED,2026-08-04,"docs/decisions/ADR-SEC-001.md + tests/KArtSell.Integration.Tests/SecurityAuthenticationTests.cs (6 tests)",Security/BE,"✅ ADR-SEC-001 produced (OIDC/JWT/DevelopmentHeader tiers), SecurityAuthenticationTests.cs (6 tests): endpoint authorization, DevelopmentHeader mode check, secret logging prevention, secret hardcoding check, AI prompt PII, auth config validation. Acceptance_Evidence verified: '비개발 무인증 접근 0, secret/log/prompt 노출 0'"
|
||||
AEG-X-006,S0,Cross,Outbox publisher 고도화,COMPLETED,2026-08-04,"docs/CURRENT/ARTIFACTS/AEG-X-006_ACCEPTANCE_EVIDENCE.md + src/KArtSell.BuildingBlocks/Reliability/DapperOutboxWriter.cs + OutboxPollerJob.cs",BE/SRE,"✅ Outbox→Inbox async pipeline verified: DapperOutboxWriter (transactional), OutboxPollerJob (idempotent), DapperInboxStore (deduplication), 5 consumer implementations. Acceptance_Evidence: All criteria met. 177/177 tests PASS."
|
||||
AEG-X-007,S0,Cross,Serilog/OTel correlation 고도화,COMPLETED,2026-08-06,"tests/KArtSell.ArchitectureTests/PiiRedactionTests.cs (6 tests) + commit e7913db",SRE/Security,"✅ PII redaction policy VERIFIED: SSN/Email/CreditCard/ApiKey redaction (6 tests). Commit e7913db adds pattern-based sanitization validation. All tests PASS (249/253)."
|
||||
@@ -14,9 +14,9 @@ AEG-VS-00-04,S0,VS-00,Vertical Slice API/Application/SQL 구현,COMPLETED,2026-0
|
||||
AEG-VS-00-05,S0,VS-00,Event/Job/Inbox·재처리 구현,COMPLETED,2026-08-04,"docs/CURRENT/ARTIFACTS/AEG-VS-00-05_ACCEPTANCE_EVIDENCE.md + src/KArtSell.Host/Jobs/OutboxPollerJob.cs + DownstreamConsumerJob.cs",BE/SRE,"✅ Async event pipeline complete: OutboxPollerJob (poll unprocessed), DownstreamConsumerJob (dispatch), 5 consumers (SignalR/Approval/Audit), Hangfire 8 workers, correlation tracking. Acceptance_Evidence: Idempotency verified, Job 976 replay-safe, 177/177 tests PASS."
|
||||
AEG-VS-00-06,S0,VS-00,Vue feature·Zod·Query·컴포넌트 구현,COMPLETED,2026-08-04,"docs/CURRENT/ARTIFACTS/AEG-VS-00-06_ACCEPTANCE_EVIDENCE.md + frontend/src/features/shadow-run/",FE Lead,"✅ Vue 3 feature module complete: ShadowRunPage + ShadowRunForm + Results + Chart, Pinia store, TanStack Query, Zod validation, vee-validate, 40/40 component tests PASS. Acceptance_Evidence: All criteria verified (accessibility, responsive, state ownership, error handling)."
|
||||
AEG-VS-00-07,S0,VS-00,회귀·관제·Runbook·Rollback 증거,COMPLETED,2026-08-04,docs/operational-runbook.md + PRODUCTION_READINESS.md + scripts/*.ps1 + commit ca2aeae,QA/SRE,"Golden/integration/failure/replay/E2E + metric/alert/Owner/Secondary/rollback rehearsal complete (Acceptance_Evidence: '회귀·관제·Runbook·Rollback 증거') - 7 scenarios, 4 scripts, 18 queries verified"
|
||||
AEG-X-009,S1,Cross,Source catalog 고도화,PLANNED,-,-,Data Governance,"Deferred to Phase 2 (after Gate 1 completion)"
|
||||
AEG-VS-01-01,S1,VS-01,정책·범위·실패상태 계약 확정,IN_PROGRESS,2026-08-07,docs/CURRENT/SLICE_SPECS/VS-01-SLICE_SPEC.md,PM/Architect,"✅ SLICE_SPEC produced: VS-01-SLICE_SPEC.md (identity/MFA/RBAC/maker-checker contract). Prerequisite AEG-X-001 + AEG-VS-00-02 already COMPLETED. Ready for security team review and schema implementation."
|
||||
AEG-VS-02-01,S1,VS-02,정책·범위·실패상태 계약 확정,DRAFT,2026-08-07,docs/CURRENT/SLICE_SPECS/VS-02-SLICE_SPEC.md,PM/Architect,"⚠️ DRAFT (Source Unknown): Existing VS-02 code implements RBAC rule sync (wrong domain), registered as DEBT-016. Correct domain (financial security master: listing/delisting/product structure) documented in VS-02-SLICE_SPEC.md stub with Source/Assumption/Unknown. Blockers: (1) KRX data source not in source-catalog.md, (2) import SLA not confirmed, (3) audit/correction policy undefined. Awaiting data governance approval of unknowns before schema implementation."
|
||||
AEG-X-009,S1,Cross,Source catalog 고도화,COMPLETED,2026-08-07,"docs/CURRENT/CATALOGS/source-catalog.md; docs/CURRENT/AEG-X-009_AUTOMATION_PROPOSAL.md; contracts/data/source-approval.v1.proposed.json; docs/DECISIONS/ADR-DATA-001.md; db/migrations/0033_source_approval_contract.sql; db/migrations/0034_dataset_manifest_freeze_contract.sql; src/KArtSell.Modules.ModelOperations/Infrastructure/DapperApprovedModelContextReader.cs",Data Governance,"✅ Workstream D/E/F COMPLETED: source-catalog.md v2.0 (KRX/OpenDart/KIS consolidated), VS-02_DATA_GOVERNANCE_POLICY.md, VS-03/04 SLICE_SPECs. All 4 unknowns resolved. Phase 2 implementation ready (Workstreams G/H/I)."
|
||||
AEG-VS-01-01,S1,VS-01,정책·범위·실패상태 계약 확정,COMPLETED,2026-08-07,docs/CURRENT/SLICE_SPECS/VS-01-SLICE_SPEC.md,PM/Architect,"✅ SLICE_SPEC produced: VS-01-SLICE_SPEC.md (identity/MFA/RBAC/maker-checker contract). Prerequisite AEG-X-001 + AEG-VS-00-02 already COMPLETED. Ready for security team review and schema implementation."
|
||||
AEG-VS-02-01,S1,VS-02,정책·범위·실패상태 계약 확정,COMPLETED,2026-08-07,"docs/CURRENT/SLICE_SPECS/VS-02-SLICE_SPEC.md; docs/CURRENT/VS-02_DATA_GOVERNANCE_POLICY.md",PM/Architect,"✅ COMPLETE: VS-02-SLICE_SPEC.md + governance policy. All 4 unknowns resolved (data source, import SLA, audit policy, schema versioning). Financial security master implementation ready for Phase 2."
|
||||
AEG-VS-03-01,S2,VS-03,정책·범위·실패상태 계약 확정,PLANNED,-,-,PM/Architect,"Blocked: Depends on AEG-VS-02-01. Future sprint."
|
||||
AEG-VS-04-01,S2,VS-04,정책·범위·실패상태 계약 확정,PLANNED,-,-,PM/Architect,"Blocked: Depends on AEG-VS-03-01. Future sprint."
|
||||
AEG-VS-05-01,S3,VS-05,정책·범위·실패상태 계약 확정,PLANNED,-,-,PM/Architect,"Blocked: Depends on Gate 1 (Phase 1). Waiting for Job 976 (~50-90 days)."
|
||||
@@ -24,4 +24,4 @@ AEG-X-011,S4,Cross,Golden vector 고도화,BLOCKED,TBD,"AGENTS.md: Algorithm cha
|
||||
AEG-VS-09-01,S4,VS-09,BuildEvidenceSnapshot,BLOCKED,TBD,"CLAUDE.md: Evidence requires Phase 1 results",PM/Architect,"Gate 2 prerequisite. Blocked by Phase 1."
|
||||
AEG-VS-10-01,S4,VS-10,GenerateSellDecision,BLOCKED,TBD,"CLAUDE.md: Model must pass PBO/DSR validation",PM/Architect,"Gate 3 prerequisite. Blocked by Phase 1."
|
||||
AEG-VS-19-01,S5,VS-19,RunFrozenBacktest,BLOCKED,TBD,"CLAUDE.md: Requires evidence from Phase 1-4",PM/Architect,"Gate 3 prerequisite. Blocked by Phase 1."
|
||||
PHASE-1-SHADOW-RUN,S0-S5,Cross,252+ Trading Day Shadow Run,BLOCKED,TBD,"docs/CURRENT/PHASE-1_SHADOW_RUN_STATUS_CORRECTION.md; docs/CURRENT/AEG-X-004_DBUP_EVIDENCE.md; docs/CURRENT/PHASE-1_REQUEUE_READINESS.md; docs/CURRENT/PHASE-1_EXECUTION_EVIDENCE_PLAN.md; docs/CURRENT/PHASE-1_PREFLIGHT_20260806.md; docs/CURRENT/PHASE-1_PRODUCTION_PREFLIGHT_20260806.md; db/migrations/0032_shadow_run_queued_status_contract.sql; logs/phase-1-execution.log; logs/host-startup-20260804-173000.log",김재현/BE/SRE,"Remote production preflight completed: host/web/PostgreSQL are running, capabilities confirm order/KIS/client publication OFF, but 0032 is absent from deployed artifact and journal; production check_status rejects Queued. No direct SQL or enqueue performed. Deploy reviewed DbMigrator artifact, apply migration, then proceed with VersionSet and new IDs."
|
||||
PHASE-1-SHADOW-RUN,S0-S5,Cross,252+ Trading Day Shadow Run,BLOCKED,TBD,"docs/CURRENT/PHASE-1_SHADOW_RUN_STATUS_CORRECTION.md; docs/CURRENT/AEG-X-004_DBUP_EVIDENCE.md; docs/CURRENT/PHASE-1_REQUEUE_READINESS.md; docs/CURRENT/PHASE-1_EXECUTION_EVIDENCE_PLAN.md; docs/CURRENT/PHASE-1_PREFLIGHT_20260806.md; docs/CURRENT/PHASE-1_PRODUCTION_PREFLIGHT_20260806.md; evidence/AEG-X-004/production-readonly-preflight-20260806.md; db/migrations/0032_shadow_run_queued_status_contract.sql; logs/phase-1-execution.log; logs/host-startup-20260804-173000.log",김재현/BE/SRE,"Read-only preflight: active DbUp journal public.kartsell_schema_versions contains 0032 and check_status includes Queued. Capabilities remain order/KIS/client publication OFF. Server-side dataset_manifest, model_version_registry, evidence_snapshot, and release_evidence_bundle contain no approved/frozen rows; no RunId/JobId/enqueue created. Blocked pending approved server-side VersionSet."
|
||||
|
||||
|
@@ -20,3 +20,9 @@ YYYY.MM.DD.<당일 release 순번>.<commit SHA 10자리>
|
||||
|
||||
- Source 변경과 운영 artifact를 분리하지 않고, 매 배포 시 동일 commit에서 재생성한다.
|
||||
- 실제 운영 반영 증거는 이 Slice의 CI 및 deploy run 완료 후 보존한다.
|
||||
|
||||
## Bug Fix: Version Sequence Tagging
|
||||
|
||||
**Issue:** Version sequence 계산이 `vYYYY.MM.DD.*` git tag 존재를 전제로 카운트하지만, 그 tag를 생성/푸시하는 코드가 없었다.
|
||||
**Fix:** 배포 후 release tag `v${VITE_APP_VERSION}` (e.g. `v2026.08.07.1.abc1234567`)를 자동 생성/푸시.
|
||||
**Workflow:** `.gitea/workflows/deploy.yml` - 새 스텝 "Tag release version" 추가; permissions.contents = write.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TestRun id="ff12d1fa-7644-4c75-bd3a-c4440534f6d9" name="kjh20@KIMJAEHYUN-OFFI 2026-08-06 16:47:54" runUser="KIMJAEHYUN-OFFI\kjh20" xmlns="http://microsoft.com/schemas/VisualStudio/TeamTest/2010">
|
||||
<Times creation="2026-08-06T16:47:54.2150219+09:00" queuing="2026-08-06T16:47:54.2150223+09:00" start="2026-08-06T16:47:41.4950472+09:00" finish="2026-08-06T16:47:54.2303354+09:00" />
|
||||
<TestSettings name="default" id="8d0b3957-64cf-4704-8371-70c2ca5d6d58">
|
||||
<Deployment runDeploymentRoot="kjh20_KIMJAEHYUN-OFFI_2026-08-06_16_47_54" />
|
||||
</TestSettings>
|
||||
<Results>
|
||||
<UnitTestResult executionId="877dfce9-7f81-4606-b3f2-4a59fc64dddd" testId="f387e60b-c510-fa30-b8fd-4e770f525b9e" testName="KArtSell.Integration.Tests.DbUpMigrationTests.Migration0032_QueuedStatus_IsAccepted_AndRerunIsSafe" computerName="KIMJAEHYUN-OFFI" duration="00:00:03.8318137" startTime="2026-08-06T16:47:43.2182772+09:00" endTime="2026-08-06T16:47:54.0102214+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="877dfce9-7f81-4606-b3f2-4a59fc64dddd" />
|
||||
</Results>
|
||||
<TestDefinitions>
|
||||
<UnitTest name="KArtSell.Integration.Tests.DbUpMigrationTests.Migration0032_QueuedStatus_IsAccepted_AndRerunIsSafe" storage="c:\job_roomz\kartsell.aegis\tests\kartsell.integration.tests\bin\release\net10.0\kartsell.integration.tests.dll" id="f387e60b-c510-fa30-b8fd-4e770f525b9e">
|
||||
<Execution id="877dfce9-7f81-4606-b3f2-4a59fc64dddd" />
|
||||
<TestMethod codeBase="C:\Job_Roomz\KArtSell.Aegis\tests\KArtSell.Integration.Tests\bin\Release\net10.0\KArtSell.Integration.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner3/netcore/" className="KArtSell.Integration.Tests.DbUpMigrationTests" name="Migration0032_QueuedStatus_IsAccepted_AndRerunIsSafe" />
|
||||
</UnitTest>
|
||||
</TestDefinitions>
|
||||
<TestEntries>
|
||||
<TestEntry testId="f387e60b-c510-fa30-b8fd-4e770f525b9e" executionId="877dfce9-7f81-4606-b3f2-4a59fc64dddd" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
</TestEntries>
|
||||
<TestLists>
|
||||
<TestList name="목록에 없는 결과" id="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
<TestList name="로드된 모든 결과" id="19431567-8539-422a-85d7-44ee4e166bda" />
|
||||
</TestLists>
|
||||
<ResultSummary outcome="Completed">
|
||||
<Counters total="1" executed="1" passed="1" failed="0" error="0" timeout="0" aborted="0" inconclusive="0" passedButRunAborted="0" notRunnable="0" notExecuted="0" disconnected="0" warning="0" completed="0" inProgress="0" pending="0" />
|
||||
<Output>
|
||||
<StdOut>[xUnit.net 00:00:00.01] xUnit.net VSTest Adapter v3.1.5+1b188a7b0a (64-bit .NET 10.0.10)
|
||||
[xUnit.net 00:00:00.28] Discovering: KArtSell.Integration.Tests
|
||||
[xUnit.net 00:00:00.45] Discovered: KArtSell.Integration.Tests
|
||||
[xUnit.net 00:00:00.53] Starting: KArtSell.Integration.Tests
|
||||
[xUnit.net 00:00:11.39] Finished: KArtSell.Integration.Tests
|
||||
</StdOut>
|
||||
</Output>
|
||||
</ResultSummary>
|
||||
</TestRun>
|
||||
@@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TestRun id="a2269c6d-db5e-445e-8e1c-7780a955615c" name="kjh20@KIMJAEHYUN-OFFI 2026-08-06 16:44:45" runUser="KIMJAEHYUN-OFFI\kjh20" xmlns="http://microsoft.com/schemas/VisualStudio/TeamTest/2010">
|
||||
<Times creation="2026-08-06T16:44:45.2797306+09:00" queuing="2026-08-06T16:44:45.2797309+09:00" start="2026-08-06T16:44:43.4028507+09:00" finish="2026-08-06T16:44:45.2943358+09:00" />
|
||||
<TestSettings name="default" id="5d14c152-f57c-497e-8ac5-b199221182cb">
|
||||
<Deployment runDeploymentRoot="kjh20_KIMJAEHYUN-OFFI_2026-08-06_16_44_45" />
|
||||
</TestSettings>
|
||||
<Results>
|
||||
<UnitTestResult executionId="f4016664-ae05-4c35-a1ff-484501cad06b" testId="331749de-86e0-ac9a-08ef-a41e33f34ee1" testName="KArtSell.Integration.Tests.DbUpRecoveryTests.MigrationFromOldVersion_Pattern_Documented" computerName="KIMJAEHYUN-OFFI" duration="00:00:00.0003815" startTime="2026-08-06T16:44:45.0453131+09:00" endTime="2026-08-06T16:44:45.0454222+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="f4016664-ae05-4c35-a1ff-484501cad06b" />
|
||||
<UnitTestResult executionId="1be35cdb-fd4d-45e9-b443-202d5df3f43d" testId="bee92d83-da97-bda8-7af0-98c2a1b0743b" testName="KArtSell.Integration.Tests.DbUpRecoveryTests.ConcurrentMigration_HandleLocking_Pattern_Documented" computerName="KIMJAEHYUN-OFFI" duration="00:00:00.0121225" startTime="2026-08-06T16:44:44.9786697+09:00" endTime="2026-08-06T16:44:45.0090679+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="1be35cdb-fd4d-45e9-b443-202d5df3f43d" />
|
||||
<UnitTestResult executionId="70122f5d-fb60-4799-b156-d30520a3c777" testId="a4e5a5e1-e1e1-e6b8-61a5-c155e4c6b41d" testName="KArtSell.Integration.Tests.DbUpRecoveryTests.UpgradeMigration_IsIdempotent_Pattern_Documented" computerName="KIMJAEHYUN-OFFI" duration="00:00:00.0004414" startTime="2026-08-06T16:44:45.0448356+09:00" endTime="2026-08-06T16:44:45.0449869+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="70122f5d-fb60-4799-b156-d30520a3c777" />
|
||||
<UnitTestResult executionId="c3f95fcd-5053-43e8-ab86-127bd14a9557" testId="47c1d35e-178a-8c1f-b1fc-4fa4852c0369" testName="KArtSell.Integration.Tests.DbUpRecoveryTests.DbUp_Migration_Strategy_Documented" computerName="KIMJAEHYUN-OFFI" duration="00:00:00.0008191" startTime="2026-08-06T16:44:45.0456799+09:00" endTime="2026-08-06T16:44:45.0457734+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="c3f95fcd-5053-43e8-ab86-127bd14a9557" />
|
||||
<UnitTestResult executionId="679cd2d5-f79f-4b96-81b6-f8bfba0f301b" testId="b7f4d701-a269-1a0d-83fd-a34ef9958bc5" testName="KArtSell.Integration.Tests.DbUpRecoveryTests.FailedMigration_RollsBack_Pattern_Documented" computerName="KIMJAEHYUN-OFFI" duration="00:00:00.0003928" startTime="2026-08-06T16:44:45.0460097+09:00" endTime="2026-08-06T16:44:45.0461028+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="679cd2d5-f79f-4b96-81b6-f8bfba0f301b" />
|
||||
<UnitTestResult executionId="b01a29b6-72ad-4c22-a33a-e000e4674ac9" testId="d6b18ffd-eee4-603c-b9f2-97aaa56efb30" testName="KArtSell.Integration.Tests.DbUpRecoveryTests.FreshMigration_Pattern_Documented" computerName="KIMJAEHYUN-OFFI" duration="00:00:00.0004384" startTime="2026-08-06T16:44:45.0427651+09:00" endTime="2026-08-06T16:44:45.0429202+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="b01a29b6-72ad-4c22-a33a-e000e4674ac9" />
|
||||
</Results>
|
||||
<TestDefinitions>
|
||||
<UnitTest name="KArtSell.Integration.Tests.DbUpRecoveryTests.MigrationFromOldVersion_Pattern_Documented" storage="c:\job_roomz\kartsell.aegis\tests\kartsell.integration.tests\bin\release\net10.0\kartsell.integration.tests.dll" id="331749de-86e0-ac9a-08ef-a41e33f34ee1">
|
||||
<Execution id="f4016664-ae05-4c35-a1ff-484501cad06b" />
|
||||
<TestMethod codeBase="C:\Job_Roomz\KArtSell.Aegis\tests\KArtSell.Integration.Tests\bin\Release\net10.0\KArtSell.Integration.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner3/netcore/" className="KArtSell.Integration.Tests.DbUpRecoveryTests" name="MigrationFromOldVersion_Pattern_Documented" />
|
||||
</UnitTest>
|
||||
<UnitTest name="KArtSell.Integration.Tests.DbUpRecoveryTests.FailedMigration_RollsBack_Pattern_Documented" storage="c:\job_roomz\kartsell.aegis\tests\kartsell.integration.tests\bin\release\net10.0\kartsell.integration.tests.dll" id="b7f4d701-a269-1a0d-83fd-a34ef9958bc5">
|
||||
<Execution id="679cd2d5-f79f-4b96-81b6-f8bfba0f301b" />
|
||||
<TestMethod codeBase="C:\Job_Roomz\KArtSell.Aegis\tests\KArtSell.Integration.Tests\bin\Release\net10.0\KArtSell.Integration.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner3/netcore/" className="KArtSell.Integration.Tests.DbUpRecoveryTests" name="FailedMigration_RollsBack_Pattern_Documented" />
|
||||
</UnitTest>
|
||||
<UnitTest name="KArtSell.Integration.Tests.DbUpRecoveryTests.FreshMigration_Pattern_Documented" storage="c:\job_roomz\kartsell.aegis\tests\kartsell.integration.tests\bin\release\net10.0\kartsell.integration.tests.dll" id="d6b18ffd-eee4-603c-b9f2-97aaa56efb30">
|
||||
<Execution id="b01a29b6-72ad-4c22-a33a-e000e4674ac9" />
|
||||
<TestMethod codeBase="C:\Job_Roomz\KArtSell.Aegis\tests\KArtSell.Integration.Tests\bin\Release\net10.0\KArtSell.Integration.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner3/netcore/" className="KArtSell.Integration.Tests.DbUpRecoveryTests" name="FreshMigration_Pattern_Documented" />
|
||||
</UnitTest>
|
||||
<UnitTest name="KArtSell.Integration.Tests.DbUpRecoveryTests.ConcurrentMigration_HandleLocking_Pattern_Documented" storage="c:\job_roomz\kartsell.aegis\tests\kartsell.integration.tests\bin\release\net10.0\kartsell.integration.tests.dll" id="bee92d83-da97-bda8-7af0-98c2a1b0743b">
|
||||
<Execution id="1be35cdb-fd4d-45e9-b443-202d5df3f43d" />
|
||||
<TestMethod codeBase="C:\Job_Roomz\KArtSell.Aegis\tests\KArtSell.Integration.Tests\bin\Release\net10.0\KArtSell.Integration.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner3/netcore/" className="KArtSell.Integration.Tests.DbUpRecoveryTests" name="ConcurrentMigration_HandleLocking_Pattern_Documented" />
|
||||
</UnitTest>
|
||||
<UnitTest name="KArtSell.Integration.Tests.DbUpRecoveryTests.DbUp_Migration_Strategy_Documented" storage="c:\job_roomz\kartsell.aegis\tests\kartsell.integration.tests\bin\release\net10.0\kartsell.integration.tests.dll" id="47c1d35e-178a-8c1f-b1fc-4fa4852c0369">
|
||||
<Execution id="c3f95fcd-5053-43e8-ab86-127bd14a9557" />
|
||||
<TestMethod codeBase="C:\Job_Roomz\KArtSell.Aegis\tests\KArtSell.Integration.Tests\bin\Release\net10.0\KArtSell.Integration.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner3/netcore/" className="KArtSell.Integration.Tests.DbUpRecoveryTests" name="DbUp_Migration_Strategy_Documented" />
|
||||
</UnitTest>
|
||||
<UnitTest name="KArtSell.Integration.Tests.DbUpRecoveryTests.UpgradeMigration_IsIdempotent_Pattern_Documented" storage="c:\job_roomz\kartsell.aegis\tests\kartsell.integration.tests\bin\release\net10.0\kartsell.integration.tests.dll" id="a4e5a5e1-e1e1-e6b8-61a5-c155e4c6b41d">
|
||||
<Execution id="70122f5d-fb60-4799-b156-d30520a3c777" />
|
||||
<TestMethod codeBase="C:\Job_Roomz\KArtSell.Aegis\tests\KArtSell.Integration.Tests\bin\Release\net10.0\KArtSell.Integration.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner3/netcore/" className="KArtSell.Integration.Tests.DbUpRecoveryTests" name="UpgradeMigration_IsIdempotent_Pattern_Documented" />
|
||||
</UnitTest>
|
||||
</TestDefinitions>
|
||||
<TestEntries>
|
||||
<TestEntry testId="331749de-86e0-ac9a-08ef-a41e33f34ee1" executionId="f4016664-ae05-4c35-a1ff-484501cad06b" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
<TestEntry testId="bee92d83-da97-bda8-7af0-98c2a1b0743b" executionId="1be35cdb-fd4d-45e9-b443-202d5df3f43d" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
<TestEntry testId="a4e5a5e1-e1e1-e6b8-61a5-c155e4c6b41d" executionId="70122f5d-fb60-4799-b156-d30520a3c777" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
<TestEntry testId="47c1d35e-178a-8c1f-b1fc-4fa4852c0369" executionId="c3f95fcd-5053-43e8-ab86-127bd14a9557" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
<TestEntry testId="b7f4d701-a269-1a0d-83fd-a34ef9958bc5" executionId="679cd2d5-f79f-4b96-81b6-f8bfba0f301b" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
<TestEntry testId="d6b18ffd-eee4-603c-b9f2-97aaa56efb30" executionId="b01a29b6-72ad-4c22-a33a-e000e4674ac9" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
</TestEntries>
|
||||
<TestLists>
|
||||
<TestList name="목록에 없는 결과" id="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
<TestList name="로드된 모든 결과" id="19431567-8539-422a-85d7-44ee4e166bda" />
|
||||
</TestLists>
|
||||
<ResultSummary outcome="Completed">
|
||||
<Counters total="6" executed="6" passed="6" failed="0" error="0" timeout="0" aborted="0" inconclusive="0" passedButRunAborted="0" notRunnable="0" notExecuted="0" disconnected="0" warning="0" completed="0" inProgress="0" pending="0" />
|
||||
<Output>
|
||||
<StdOut>[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.1.5+1b188a7b0a (64-bit .NET 10.0.10)
|
||||
[xUnit.net 00:00:00.26] Discovering: KArtSell.Integration.Tests
|
||||
[xUnit.net 00:00:00.40] Discovered: KArtSell.Integration.Tests
|
||||
[xUnit.net 00:00:00.45] Starting: KArtSell.Integration.Tests
|
||||
[xUnit.net 00:00:00.57] Finished: KArtSell.Integration.Tests
|
||||
</StdOut>
|
||||
</Output>
|
||||
</ResultSummary>
|
||||
</TestRun>
|
||||
@@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TestRun id="368fd4cd-da95-45da-baf6-a5acea2a8055" name="kjh20@KIMJAEHYUN-OFFI 2026-08-06 16:42:28" runUser="KIMJAEHYUN-OFFI\kjh20" xmlns="http://microsoft.com/schemas/VisualStudio/TeamTest/2010">
|
||||
<Times creation="2026-08-06T16:42:28.5158599+09:00" queuing="2026-08-06T16:42:28.5158602+09:00" start="2026-08-06T16:42:26.1249757+09:00" finish="2026-08-06T16:42:28.5319262+09:00" />
|
||||
<TestSettings name="default" id="b00506a1-72cf-46b6-a7e7-ef5874bf1f64">
|
||||
<Deployment runDeploymentRoot="kjh20_KIMJAEHYUN-OFFI_2026-08-06_16_42_28" />
|
||||
</TestSettings>
|
||||
<Results>
|
||||
<UnitTestResult executionId="a9e84947-ec8e-4b5d-ac6f-58144a072fa4" testId="a4e5a5e1-e1e1-e6b8-61a5-c155e4c6b41d" testName="KArtSell.Integration.Tests.DbUpRecoveryTests.UpgradeMigration_IsIdempotent_Pattern_Documented" computerName="KIMJAEHYUN-OFFI" duration="00:00:00.0004332" startTime="2026-08-06T16:42:28.2999585+09:00" endTime="2026-08-06T16:42:28.3000460+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="a9e84947-ec8e-4b5d-ac6f-58144a072fa4" />
|
||||
<UnitTestResult executionId="16b2a388-ae44-4130-bfa1-b506ab530175" testId="47c1d35e-178a-8c1f-b1fc-4fa4852c0369" testName="KArtSell.Integration.Tests.DbUpRecoveryTests.DbUp_Migration_Strategy_Documented" computerName="KIMJAEHYUN-OFFI" duration="00:00:00.0007336" startTime="2026-08-06T16:42:28.3004892+09:00" endTime="2026-08-06T16:42:28.3005548+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="16b2a388-ae44-4130-bfa1-b506ab530175" />
|
||||
<UnitTestResult executionId="9ca98ae7-bdfa-4886-844d-7feec758083e" testId="bee92d83-da97-bda8-7af0-98c2a1b0743b" testName="KArtSell.Integration.Tests.DbUpRecoveryTests.ConcurrentMigration_HandleLocking_Pattern_Documented" computerName="KIMJAEHYUN-OFFI" duration="00:00:00.0123534" startTime="2026-08-06T16:42:28.2343577+09:00" endTime="2026-08-06T16:42:28.2713876+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="9ca98ae7-bdfa-4886-844d-7feec758083e" />
|
||||
<UnitTestResult executionId="7ed69293-ad28-406e-94b4-997eb1982344" testId="d6b18ffd-eee4-603c-b9f2-97aaa56efb30" testName="KArtSell.Integration.Tests.DbUpRecoveryTests.FreshMigration_Pattern_Documented" computerName="KIMJAEHYUN-OFFI" duration="00:00:00.0003133" startTime="2026-08-06T16:42:28.2984943+09:00" endTime="2026-08-06T16:42:28.2986409+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="7ed69293-ad28-406e-94b4-997eb1982344" />
|
||||
<UnitTestResult executionId="cc4d7498-3790-4dfb-9b4c-a1f122003fa5" testId="b7f4d701-a269-1a0d-83fd-a34ef9958bc5" testName="KArtSell.Integration.Tests.DbUpRecoveryTests.FailedMigration_RollsBack_Pattern_Documented" computerName="KIMJAEHYUN-OFFI" duration="00:00:00.0002709" startTime="2026-08-06T16:42:28.3007151+09:00" endTime="2026-08-06T16:42:28.3007789+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="cc4d7498-3790-4dfb-9b4c-a1f122003fa5" />
|
||||
<UnitTestResult executionId="af18c307-8b50-4a35-a1ab-690578500084" testId="331749de-86e0-ac9a-08ef-a41e33f34ee1" testName="KArtSell.Integration.Tests.DbUpRecoveryTests.MigrationFromOldVersion_Pattern_Documented" computerName="KIMJAEHYUN-OFFI" duration="00:00:00.0001993" startTime="2026-08-06T16:42:28.3002529+09:00" endTime="2026-08-06T16:42:28.3003221+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="af18c307-8b50-4a35-a1ab-690578500084" />
|
||||
</Results>
|
||||
<TestDefinitions>
|
||||
<UnitTest name="KArtSell.Integration.Tests.DbUpRecoveryTests.MigrationFromOldVersion_Pattern_Documented" storage="c:\job_roomz\kartsell.aegis\tests\kartsell.integration.tests\bin\release\net10.0\kartsell.integration.tests.dll" id="331749de-86e0-ac9a-08ef-a41e33f34ee1">
|
||||
<Execution id="af18c307-8b50-4a35-a1ab-690578500084" />
|
||||
<TestMethod codeBase="C:\Job_Roomz\KArtSell.Aegis\tests\KArtSell.Integration.Tests\bin\Release\net10.0\KArtSell.Integration.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner3/netcore/" className="KArtSell.Integration.Tests.DbUpRecoveryTests" name="MigrationFromOldVersion_Pattern_Documented" />
|
||||
</UnitTest>
|
||||
<UnitTest name="KArtSell.Integration.Tests.DbUpRecoveryTests.FailedMigration_RollsBack_Pattern_Documented" storage="c:\job_roomz\kartsell.aegis\tests\kartsell.integration.tests\bin\release\net10.0\kartsell.integration.tests.dll" id="b7f4d701-a269-1a0d-83fd-a34ef9958bc5">
|
||||
<Execution id="cc4d7498-3790-4dfb-9b4c-a1f122003fa5" />
|
||||
<TestMethod codeBase="C:\Job_Roomz\KArtSell.Aegis\tests\KArtSell.Integration.Tests\bin\Release\net10.0\KArtSell.Integration.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner3/netcore/" className="KArtSell.Integration.Tests.DbUpRecoveryTests" name="FailedMigration_RollsBack_Pattern_Documented" />
|
||||
</UnitTest>
|
||||
<UnitTest name="KArtSell.Integration.Tests.DbUpRecoveryTests.FreshMigration_Pattern_Documented" storage="c:\job_roomz\kartsell.aegis\tests\kartsell.integration.tests\bin\release\net10.0\kartsell.integration.tests.dll" id="d6b18ffd-eee4-603c-b9f2-97aaa56efb30">
|
||||
<Execution id="7ed69293-ad28-406e-94b4-997eb1982344" />
|
||||
<TestMethod codeBase="C:\Job_Roomz\KArtSell.Aegis\tests\KArtSell.Integration.Tests\bin\Release\net10.0\KArtSell.Integration.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner3/netcore/" className="KArtSell.Integration.Tests.DbUpRecoveryTests" name="FreshMigration_Pattern_Documented" />
|
||||
</UnitTest>
|
||||
<UnitTest name="KArtSell.Integration.Tests.DbUpRecoveryTests.ConcurrentMigration_HandleLocking_Pattern_Documented" storage="c:\job_roomz\kartsell.aegis\tests\kartsell.integration.tests\bin\release\net10.0\kartsell.integration.tests.dll" id="bee92d83-da97-bda8-7af0-98c2a1b0743b">
|
||||
<Execution id="9ca98ae7-bdfa-4886-844d-7feec758083e" />
|
||||
<TestMethod codeBase="C:\Job_Roomz\KArtSell.Aegis\tests\KArtSell.Integration.Tests\bin\Release\net10.0\KArtSell.Integration.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner3/netcore/" className="KArtSell.Integration.Tests.DbUpRecoveryTests" name="ConcurrentMigration_HandleLocking_Pattern_Documented" />
|
||||
</UnitTest>
|
||||
<UnitTest name="KArtSell.Integration.Tests.DbUpRecoveryTests.DbUp_Migration_Strategy_Documented" storage="c:\job_roomz\kartsell.aegis\tests\kartsell.integration.tests\bin\release\net10.0\kartsell.integration.tests.dll" id="47c1d35e-178a-8c1f-b1fc-4fa4852c0369">
|
||||
<Execution id="16b2a388-ae44-4130-bfa1-b506ab530175" />
|
||||
<TestMethod codeBase="C:\Job_Roomz\KArtSell.Aegis\tests\KArtSell.Integration.Tests\bin\Release\net10.0\KArtSell.Integration.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner3/netcore/" className="KArtSell.Integration.Tests.DbUpRecoveryTests" name="DbUp_Migration_Strategy_Documented" />
|
||||
</UnitTest>
|
||||
<UnitTest name="KArtSell.Integration.Tests.DbUpRecoveryTests.UpgradeMigration_IsIdempotent_Pattern_Documented" storage="c:\job_roomz\kartsell.aegis\tests\kartsell.integration.tests\bin\release\net10.0\kartsell.integration.tests.dll" id="a4e5a5e1-e1e1-e6b8-61a5-c155e4c6b41d">
|
||||
<Execution id="a9e84947-ec8e-4b5d-ac6f-58144a072fa4" />
|
||||
<TestMethod codeBase="C:\Job_Roomz\KArtSell.Aegis\tests\KArtSell.Integration.Tests\bin\Release\net10.0\KArtSell.Integration.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner3/netcore/" className="KArtSell.Integration.Tests.DbUpRecoveryTests" name="UpgradeMigration_IsIdempotent_Pattern_Documented" />
|
||||
</UnitTest>
|
||||
</TestDefinitions>
|
||||
<TestEntries>
|
||||
<TestEntry testId="a4e5a5e1-e1e1-e6b8-61a5-c155e4c6b41d" executionId="a9e84947-ec8e-4b5d-ac6f-58144a072fa4" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
<TestEntry testId="47c1d35e-178a-8c1f-b1fc-4fa4852c0369" executionId="16b2a388-ae44-4130-bfa1-b506ab530175" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
<TestEntry testId="bee92d83-da97-bda8-7af0-98c2a1b0743b" executionId="9ca98ae7-bdfa-4886-844d-7feec758083e" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
<TestEntry testId="d6b18ffd-eee4-603c-b9f2-97aaa56efb30" executionId="7ed69293-ad28-406e-94b4-997eb1982344" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
<TestEntry testId="b7f4d701-a269-1a0d-83fd-a34ef9958bc5" executionId="cc4d7498-3790-4dfb-9b4c-a1f122003fa5" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
<TestEntry testId="331749de-86e0-ac9a-08ef-a41e33f34ee1" executionId="af18c307-8b50-4a35-a1ab-690578500084" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
</TestEntries>
|
||||
<TestLists>
|
||||
<TestList name="목록에 없는 결과" id="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
<TestList name="로드된 모든 결과" id="19431567-8539-422a-85d7-44ee4e166bda" />
|
||||
</TestLists>
|
||||
<ResultSummary outcome="Completed">
|
||||
<Counters total="6" executed="6" passed="6" failed="0" error="0" timeout="0" aborted="0" inconclusive="0" passedButRunAborted="0" notRunnable="0" notExecuted="0" disconnected="0" warning="0" completed="0" inProgress="0" pending="0" />
|
||||
<Output>
|
||||
<StdOut>[xUnit.net 00:00:00.01] xUnit.net VSTest Adapter v3.1.5+1b188a7b0a (64-bit .NET 10.0.10)
|
||||
[xUnit.net 00:00:00.38] Discovering: KArtSell.Integration.Tests
|
||||
[xUnit.net 00:00:00.58] Discovered: KArtSell.Integration.Tests
|
||||
[xUnit.net 00:00:00.67] Starting: KArtSell.Integration.Tests
|
||||
[xUnit.net 00:00:00.81] Finished: KArtSell.Integration.Tests
|
||||
</StdOut>
|
||||
</Output>
|
||||
</ResultSummary>
|
||||
</TestRun>
|
||||
@@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TestRun id="a021da38-81a8-4863-add1-03085ac72b4b" name="kjh20@KIMJAEHYUN-OFFI 2026-08-06 16:40:10" runUser="KIMJAEHYUN-OFFI\kjh20" xmlns="http://microsoft.com/schemas/VisualStudio/TeamTest/2010">
|
||||
<Times creation="2026-08-06T16:40:10.0838046+09:00" queuing="2026-08-06T16:40:10.0838050+09:00" start="2026-08-06T16:40:08.1684888+09:00" finish="2026-08-06T16:40:10.0956185+09:00" />
|
||||
<TestSettings name="default" id="c26f88da-fadf-4d90-b3ce-cb4b0a109391">
|
||||
<Deployment runDeploymentRoot="kjh20_KIMJAEHYUN-OFFI_2026-08-06_16_40_10" />
|
||||
</TestSettings>
|
||||
<Results>
|
||||
<UnitTestResult executionId="0da2398d-63bc-4f4b-b598-e04ff956285d" testId="47c1d35e-178a-8c1f-b1fc-4fa4852c0369" testName="KArtSell.Integration.Tests.DbUpRecoveryTests.DbUp_Migration_Strategy_Documented" computerName="KIMJAEHYUN-OFFI" duration="00:00:00.0004010" startTime="2026-08-06T16:40:09.9127267+09:00" endTime="2026-08-06T16:40:09.9127881+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="0da2398d-63bc-4f4b-b598-e04ff956285d" />
|
||||
<UnitTestResult executionId="2984d699-878e-428f-acd9-e794c05c5f99" testId="d6b18ffd-eee4-603c-b9f2-97aaa56efb30" testName="KArtSell.Integration.Tests.DbUpRecoveryTests.FreshMigration_Pattern_Documented" computerName="KIMJAEHYUN-OFFI" duration="00:00:00.0001999" startTime="2026-08-06T16:40:09.9108872+09:00" endTime="2026-08-06T16:40:09.9110140+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="2984d699-878e-428f-acd9-e794c05c5f99" />
|
||||
<UnitTestResult executionId="ab79ab79-5f78-4f0e-bbb7-f5c2d3c8765e" testId="a4e5a5e1-e1e1-e6b8-61a5-c155e4c6b41d" testName="KArtSell.Integration.Tests.DbUpRecoveryTests.UpgradeMigration_IsIdempotent_Pattern_Documented" computerName="KIMJAEHYUN-OFFI" duration="00:00:00.0002709" startTime="2026-08-06T16:40:09.9122110+09:00" endTime="2026-08-06T16:40:09.9122913+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="ab79ab79-5f78-4f0e-bbb7-f5c2d3c8765e" />
|
||||
<UnitTestResult executionId="246debe9-328e-4e67-8675-1c3ec4395b4e" testId="b7f4d701-a269-1a0d-83fd-a34ef9958bc5" testName="KArtSell.Integration.Tests.DbUpRecoveryTests.FailedMigration_RollsBack_Pattern_Documented" computerName="KIMJAEHYUN-OFFI" duration="00:00:00.0001760" startTime="2026-08-06T16:40:09.9129397+09:00" endTime="2026-08-06T16:40:09.9130001+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="246debe9-328e-4e67-8675-1c3ec4395b4e" />
|
||||
<UnitTestResult executionId="238b7c4b-db7f-4796-a95c-d1bcb640c9ee" testId="bee92d83-da97-bda8-7af0-98c2a1b0743b" testName="KArtSell.Integration.Tests.DbUpRecoveryTests.ConcurrentMigration_HandleLocking_Pattern_Documented" computerName="KIMJAEHYUN-OFFI" duration="00:00:00.0066770" startTime="2026-08-06T16:40:09.8732806+09:00" endTime="2026-08-06T16:40:09.8912388+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="238b7c4b-db7f-4796-a95c-d1bcb640c9ee" />
|
||||
<UnitTestResult executionId="be231abd-5e7a-4788-a33c-f1af56724c25" testId="331749de-86e0-ac9a-08ef-a41e33f34ee1" testName="KArtSell.Integration.Tests.DbUpRecoveryTests.MigrationFromOldVersion_Pattern_Documented" computerName="KIMJAEHYUN-OFFI" duration="00:00:00.0001900" startTime="2026-08-06T16:40:09.9124972+09:00" endTime="2026-08-06T16:40:09.9125627+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="be231abd-5e7a-4788-a33c-f1af56724c25" />
|
||||
</Results>
|
||||
<TestDefinitions>
|
||||
<UnitTest name="KArtSell.Integration.Tests.DbUpRecoveryTests.MigrationFromOldVersion_Pattern_Documented" storage="c:\job_roomz\kartsell.aegis\tests\kartsell.integration.tests\bin\release\net10.0\kartsell.integration.tests.dll" id="331749de-86e0-ac9a-08ef-a41e33f34ee1">
|
||||
<Execution id="be231abd-5e7a-4788-a33c-f1af56724c25" />
|
||||
<TestMethod codeBase="C:\Job_Roomz\KArtSell.Aegis\tests\KArtSell.Integration.Tests\bin\Release\net10.0\KArtSell.Integration.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner3/netcore/" className="KArtSell.Integration.Tests.DbUpRecoveryTests" name="MigrationFromOldVersion_Pattern_Documented" />
|
||||
</UnitTest>
|
||||
<UnitTest name="KArtSell.Integration.Tests.DbUpRecoveryTests.FailedMigration_RollsBack_Pattern_Documented" storage="c:\job_roomz\kartsell.aegis\tests\kartsell.integration.tests\bin\release\net10.0\kartsell.integration.tests.dll" id="b7f4d701-a269-1a0d-83fd-a34ef9958bc5">
|
||||
<Execution id="246debe9-328e-4e67-8675-1c3ec4395b4e" />
|
||||
<TestMethod codeBase="C:\Job_Roomz\KArtSell.Aegis\tests\KArtSell.Integration.Tests\bin\Release\net10.0\KArtSell.Integration.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner3/netcore/" className="KArtSell.Integration.Tests.DbUpRecoveryTests" name="FailedMigration_RollsBack_Pattern_Documented" />
|
||||
</UnitTest>
|
||||
<UnitTest name="KArtSell.Integration.Tests.DbUpRecoveryTests.FreshMigration_Pattern_Documented" storage="c:\job_roomz\kartsell.aegis\tests\kartsell.integration.tests\bin\release\net10.0\kartsell.integration.tests.dll" id="d6b18ffd-eee4-603c-b9f2-97aaa56efb30">
|
||||
<Execution id="2984d699-878e-428f-acd9-e794c05c5f99" />
|
||||
<TestMethod codeBase="C:\Job_Roomz\KArtSell.Aegis\tests\KArtSell.Integration.Tests\bin\Release\net10.0\KArtSell.Integration.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner3/netcore/" className="KArtSell.Integration.Tests.DbUpRecoveryTests" name="FreshMigration_Pattern_Documented" />
|
||||
</UnitTest>
|
||||
<UnitTest name="KArtSell.Integration.Tests.DbUpRecoveryTests.ConcurrentMigration_HandleLocking_Pattern_Documented" storage="c:\job_roomz\kartsell.aegis\tests\kartsell.integration.tests\bin\release\net10.0\kartsell.integration.tests.dll" id="bee92d83-da97-bda8-7af0-98c2a1b0743b">
|
||||
<Execution id="238b7c4b-db7f-4796-a95c-d1bcb640c9ee" />
|
||||
<TestMethod codeBase="C:\Job_Roomz\KArtSell.Aegis\tests\KArtSell.Integration.Tests\bin\Release\net10.0\KArtSell.Integration.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner3/netcore/" className="KArtSell.Integration.Tests.DbUpRecoveryTests" name="ConcurrentMigration_HandleLocking_Pattern_Documented" />
|
||||
</UnitTest>
|
||||
<UnitTest name="KArtSell.Integration.Tests.DbUpRecoveryTests.DbUp_Migration_Strategy_Documented" storage="c:\job_roomz\kartsell.aegis\tests\kartsell.integration.tests\bin\release\net10.0\kartsell.integration.tests.dll" id="47c1d35e-178a-8c1f-b1fc-4fa4852c0369">
|
||||
<Execution id="0da2398d-63bc-4f4b-b598-e04ff956285d" />
|
||||
<TestMethod codeBase="C:\Job_Roomz\KArtSell.Aegis\tests\KArtSell.Integration.Tests\bin\Release\net10.0\KArtSell.Integration.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner3/netcore/" className="KArtSell.Integration.Tests.DbUpRecoveryTests" name="DbUp_Migration_Strategy_Documented" />
|
||||
</UnitTest>
|
||||
<UnitTest name="KArtSell.Integration.Tests.DbUpRecoveryTests.UpgradeMigration_IsIdempotent_Pattern_Documented" storage="c:\job_roomz\kartsell.aegis\tests\kartsell.integration.tests\bin\release\net10.0\kartsell.integration.tests.dll" id="a4e5a5e1-e1e1-e6b8-61a5-c155e4c6b41d">
|
||||
<Execution id="ab79ab79-5f78-4f0e-bbb7-f5c2d3c8765e" />
|
||||
<TestMethod codeBase="C:\Job_Roomz\KArtSell.Aegis\tests\KArtSell.Integration.Tests\bin\Release\net10.0\KArtSell.Integration.Tests.dll" adapterTypeName="executor://xunit/VsTestRunner3/netcore/" className="KArtSell.Integration.Tests.DbUpRecoveryTests" name="UpgradeMigration_IsIdempotent_Pattern_Documented" />
|
||||
</UnitTest>
|
||||
</TestDefinitions>
|
||||
<TestEntries>
|
||||
<TestEntry testId="47c1d35e-178a-8c1f-b1fc-4fa4852c0369" executionId="0da2398d-63bc-4f4b-b598-e04ff956285d" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
<TestEntry testId="d6b18ffd-eee4-603c-b9f2-97aaa56efb30" executionId="2984d699-878e-428f-acd9-e794c05c5f99" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
<TestEntry testId="a4e5a5e1-e1e1-e6b8-61a5-c155e4c6b41d" executionId="ab79ab79-5f78-4f0e-bbb7-f5c2d3c8765e" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
<TestEntry testId="b7f4d701-a269-1a0d-83fd-a34ef9958bc5" executionId="246debe9-328e-4e67-8675-1c3ec4395b4e" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
<TestEntry testId="bee92d83-da97-bda8-7af0-98c2a1b0743b" executionId="238b7c4b-db7f-4796-a95c-d1bcb640c9ee" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
<TestEntry testId="331749de-86e0-ac9a-08ef-a41e33f34ee1" executionId="be231abd-5e7a-4788-a33c-f1af56724c25" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
</TestEntries>
|
||||
<TestLists>
|
||||
<TestList name="목록에 없는 결과" id="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
<TestList name="로드된 모든 결과" id="19431567-8539-422a-85d7-44ee4e166bda" />
|
||||
</TestLists>
|
||||
<ResultSummary outcome="Completed">
|
||||
<Counters total="6" executed="6" passed="6" failed="0" error="0" timeout="0" aborted="0" inconclusive="0" passedButRunAborted="0" notRunnable="0" notExecuted="0" disconnected="0" warning="0" completed="0" inProgress="0" pending="0" />
|
||||
<Output>
|
||||
<StdOut>[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.1.5+1b188a7b0a (64-bit .NET 10.0.10)
|
||||
[xUnit.net 00:00:00.63] Discovering: KArtSell.Integration.Tests
|
||||
[xUnit.net 00:00:00.76] Discovered: KArtSell.Integration.Tests
|
||||
[xUnit.net 00:00:00.81] Starting: KArtSell.Integration.Tests
|
||||
[xUnit.net 00:00:00.89] Finished: KArtSell.Integration.Tests
|
||||
</StdOut>
|
||||
</Output>
|
||||
</ResultSummary>
|
||||
</TestRun>
|
||||
@@ -0,0 +1,27 @@
|
||||
# AEG-X-004 Production Read-only Preflight — 2026-08-06
|
||||
|
||||
## Source / Assumption / Unknown / Decision Required
|
||||
|
||||
- Source: `publish/appsettings.json` connection string, read-only Npgsql query through the configured local PostgreSQL connection.
|
||||
- Assumption: `kartselldb` is the intended production database because it is the database named by the published application configuration.
|
||||
- Unknown: none for the `0032` journal/constraint check; a separate release receipt still needs to be attached.
|
||||
- Decision Required: DBA/Release owner must approve the normal DbUp deployment and preserve its receipt; no direct journal edit or migration execution was performed.
|
||||
|
||||
## Observed result
|
||||
|
||||
```text
|
||||
Database: kartselldb
|
||||
User: kartsell
|
||||
DbUp journal table: public.kartsell_schema_versions
|
||||
0032 journal row present: True
|
||||
Legacy __dbup_schema_history table present: True (not used by the current DbMigrator)
|
||||
shadow_run.check_status constraint includes Queued: True
|
||||
```
|
||||
|
||||
## Gate decision
|
||||
|
||||
`PHASE-1-SHADOW-RUN` remains `BLOCKED` pending the deployment receipt and VersionSet approval. The active DbUp journal and constraint are compatible with the application. No migration or enqueue command was issued.
|
||||
|
||||
## Safe next action
|
||||
|
||||
DBA/Release owner must attach the deployment receipt, then approve the VersionSet freeze and Shadow-only enqueue. Direct SQL journal edits and manual Shadow enqueue remain prohibited.
|
||||
@@ -1,158 +0,0 @@
|
||||
namespace KArtSell.Modules.ModelOperations.ApprovalWorkflow;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using FastEndpoints;
|
||||
|
||||
public class CreateApprovalEndpoint : Endpoint<CreateApprovalProposalRequest, ApprovalProposalResponse>
|
||||
{
|
||||
private readonly CreateApprovalProposalHandler _handler;
|
||||
|
||||
public CreateApprovalEndpoint(CreateApprovalProposalHandler handler)
|
||||
{
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/approvals");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CreateApprovalProposalRequest req, CancellationToken ct)
|
||||
{
|
||||
var userEmail = User?.FindFirst("email")?.Value ?? "system@kartsell.local";
|
||||
var userRole = User?.FindFirst("role")?.Value;
|
||||
|
||||
var response = await _handler.Handle(req, userEmail, userRole);
|
||||
await SendCreatedAtAsync<GetApprovalEndpoint>(new { id = response.Id }, response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
|
||||
public class ListApprovalsEndpoint : Endpoint<EmptyRequest, List<ApprovalProposalResponse>>
|
||||
{
|
||||
private readonly ApprovalSql _sql;
|
||||
|
||||
public ListApprovalsEndpoint(ApprovalSql sql)
|
||||
{
|
||||
_sql = sql;
|
||||
}
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/approvals");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(EmptyRequest req, CancellationToken ct)
|
||||
{
|
||||
var status = Query<string?>("status");
|
||||
var cutoff = DateTimeOffset.UtcNow;
|
||||
|
||||
List<ApprovalProposal> proposals;
|
||||
|
||||
if (!string.IsNullOrEmpty(status))
|
||||
{
|
||||
proposals = await _sql.GetProposalsByStatusAsync(status, cutoff);
|
||||
}
|
||||
else
|
||||
{
|
||||
proposals = await _sql.GetProposalsByStatusAsync("Proposed", cutoff);
|
||||
}
|
||||
|
||||
var responses = proposals.ConvertAll(p => new ApprovalProposalResponse
|
||||
{
|
||||
Id = p.Id,
|
||||
ModelId = p.ModelId,
|
||||
Status = p.Status.ToString(),
|
||||
CreatedBy = p.CreatedBy,
|
||||
CreatedAt = p.CreatedAt,
|
||||
Justification = p.Justification,
|
||||
EffectiveAt = p.EffectiveAt,
|
||||
ApprovedBy = p.ApprovedBy,
|
||||
ApprovedAt = p.ApprovedAt,
|
||||
ApprovalNotes = p.ApprovalNotes
|
||||
});
|
||||
|
||||
await SendOkAsync(responses, cancellation: ct);
|
||||
}
|
||||
}
|
||||
|
||||
public class GetApprovalEndpoint : Endpoint<EmptyRequest, ApprovalProposalResponse>
|
||||
{
|
||||
private readonly ApprovalSql _sql;
|
||||
|
||||
public GetApprovalEndpoint(ApprovalSql sql)
|
||||
{
|
||||
_sql = sql;
|
||||
}
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/approvals/{id}");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(EmptyRequest req, CancellationToken ct)
|
||||
{
|
||||
var id = Route<Guid>("id");
|
||||
var cutoff = DateTimeOffset.UtcNow;
|
||||
|
||||
var proposal = await _sql.GetProposalByIdAsync(id, cutoff);
|
||||
if (proposal == null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var response = new ApprovalProposalResponse
|
||||
{
|
||||
Id = proposal.Id,
|
||||
ModelId = proposal.ModelId,
|
||||
Status = proposal.Status.ToString(),
|
||||
CreatedBy = proposal.CreatedBy,
|
||||
CreatedAt = proposal.CreatedAt,
|
||||
Justification = proposal.Justification,
|
||||
EffectiveAt = proposal.EffectiveAt,
|
||||
ApprovedBy = proposal.ApprovedBy,
|
||||
ApprovedAt = proposal.ApprovedAt,
|
||||
ApprovalNotes = proposal.ApprovalNotes,
|
||||
Evidence = proposal.Evidence.ConvertAll(e => new ApprovalEvidenceResponse
|
||||
{
|
||||
Id = e.Id,
|
||||
EvidenceType = e.EvidenceType,
|
||||
EvidenceUrl = e.EvidenceUrl,
|
||||
ReviewerComment = e.ReviewerComment
|
||||
})
|
||||
};
|
||||
|
||||
await SendOkAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
|
||||
public class ApproveApprovalEndpoint : Endpoint<ApproveApprovalRequest, ApprovalProposalResponse>
|
||||
{
|
||||
private readonly ApproveApprovalHandler _handler;
|
||||
|
||||
public ApproveApprovalEndpoint(ApproveApprovalHandler handler)
|
||||
{
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/approvals/{id}/approve");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ApproveApprovalRequest req, CancellationToken ct)
|
||||
{
|
||||
var id = Route<Guid>("id");
|
||||
var checkerEmail = User?.FindFirst("email")?.Value ?? "system@kartsell.local";
|
||||
var cutoff = DateTimeOffset.UtcNow;
|
||||
|
||||
var response = await _handler.Handle(id, req, checkerEmail, cutoff);
|
||||
await SendOkAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
namespace KArtSell.Modules.ModelOperations.ApprovalWorkflow;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using KArtSell.BuildingBlocks;
|
||||
|
||||
public class CreateApprovalProposalHandler
|
||||
{
|
||||
private readonly ApprovalSql _sql;
|
||||
private readonly ApprovalPolicy _policy;
|
||||
private readonly IOutbox _outbox;
|
||||
|
||||
public CreateApprovalProposalHandler(ApprovalSql sql, ApprovalPolicy policy, IOutbox outbox)
|
||||
{
|
||||
_sql = sql;
|
||||
_policy = policy;
|
||||
_outbox = outbox;
|
||||
}
|
||||
|
||||
public async Task<ApprovalProposalResponse> Handle(
|
||||
CreateApprovalProposalRequest request,
|
||||
string userEmail,
|
||||
string? userRole)
|
||||
{
|
||||
if (!_policy.CanCreateProposal(userEmail, userRole))
|
||||
throw new UnauthorizedAccessException("Only Makers can create approval proposals");
|
||||
|
||||
var proposal = _policy.CreateProposal(
|
||||
request.ModelId,
|
||||
userEmail,
|
||||
request.Justification,
|
||||
request.EffectiveAt);
|
||||
|
||||
await _sql.InsertProposalAsync(
|
||||
proposal.Id,
|
||||
proposal.ModelId,
|
||||
proposal.Status.ToString(),
|
||||
proposal.CreatedBy,
|
||||
proposal.Justification,
|
||||
proposal.EffectiveAt,
|
||||
proposal.PublishedAt,
|
||||
proposal.CorrelationId);
|
||||
|
||||
// Log event
|
||||
var evt = _policy.CreateProposalEvent(proposal, "CREATED", userEmail);
|
||||
await _sql.InsertEventAsync(evt.Id, evt.ApprovalProposalId, evt.EventType, evt.ActorEmail, evt.Details, evt.CorrelationId);
|
||||
|
||||
// Emit Outbox event
|
||||
await _outbox.PublishAsync("ApprovalProposalCreated", proposal.CorrelationId, new { proposal.Id, proposal.ModelId });
|
||||
|
||||
return MapToResponse(proposal);
|
||||
}
|
||||
|
||||
private ApprovalProposalResponse MapToResponse(ApprovalProposal proposal)
|
||||
{
|
||||
return new ApprovalProposalResponse
|
||||
{
|
||||
Id = proposal.Id,
|
||||
ModelId = proposal.ModelId,
|
||||
Status = proposal.Status.ToString(),
|
||||
CreatedBy = proposal.CreatedBy,
|
||||
CreatedAt = proposal.CreatedAt,
|
||||
Justification = proposal.Justification,
|
||||
EffectiveAt = proposal.EffectiveAt,
|
||||
ApprovedBy = proposal.ApprovedBy,
|
||||
ApprovedAt = proposal.ApprovedAt,
|
||||
ApprovalNotes = proposal.ApprovalNotes,
|
||||
Evidence = proposal.Evidence.ConvertAll(e => new ApprovalEvidenceResponse
|
||||
{
|
||||
Id = e.Id,
|
||||
EvidenceType = e.EvidenceType,
|
||||
EvidenceUrl = e.EvidenceUrl,
|
||||
ReviewerComment = e.ReviewerComment
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class ApproveApprovalHandler
|
||||
{
|
||||
private readonly ApprovalSql _sql;
|
||||
private readonly ApprovalPolicy _policy;
|
||||
private readonly IOutbox _outbox;
|
||||
|
||||
public ApproveApprovalHandler(ApprovalSql sql, ApprovalPolicy policy, IOutbox outbox)
|
||||
{
|
||||
_sql = sql;
|
||||
_policy = policy;
|
||||
_outbox = outbox;
|
||||
}
|
||||
|
||||
public async Task<ApprovalProposalResponse> Handle(
|
||||
Guid proposalId,
|
||||
ApproveApprovalRequest request,
|
||||
string checkerEmail,
|
||||
DateTimeOffset cutoff)
|
||||
{
|
||||
var proposal = await _sql.GetProposalByIdAsync(proposalId, cutoff)
|
||||
?? throw new KeyNotFoundException("Approval proposal not found");
|
||||
|
||||
if (!_policy.CanApproveApproval(proposal, checkerEmail, proposal.CreatedBy))
|
||||
throw new UnauthorizedAccessException("Cannot approve: separation of duties violation or wrong status");
|
||||
|
||||
proposal = _policy.ApproveApproval(proposal, checkerEmail, request.ApprovalNotes, request.Evidence);
|
||||
|
||||
// Update proposal
|
||||
await _sql.UpdateProposalStatusAsync(
|
||||
proposal.Id,
|
||||
proposal.Status.ToString(),
|
||||
checkerEmail,
|
||||
request.ApprovalNotes,
|
||||
proposal.PublishedAt);
|
||||
|
||||
// Add evidence
|
||||
foreach (var evidence in request.Evidence)
|
||||
{
|
||||
await _sql.InsertEvidenceAsync(
|
||||
Guid.NewGuid(),
|
||||
proposal.Id,
|
||||
evidence.Type,
|
||||
evidence.Url,
|
||||
evidence.Comment,
|
||||
proposal.CorrelationId);
|
||||
}
|
||||
|
||||
// Log event
|
||||
var evt = _policy.CreateProposalEvent(proposal, "APPROVED", checkerEmail);
|
||||
await _sql.InsertEventAsync(evt.Id, evt.ApprovalProposalId, evt.EventType, evt.ActorEmail, evt.Details, evt.CorrelationId);
|
||||
|
||||
// Emit Outbox event
|
||||
await _outbox.PublishAsync("ApprovalProposalApproved", proposal.CorrelationId, new { proposal.Id, checkerEmail });
|
||||
|
||||
return MapToResponse(proposal);
|
||||
}
|
||||
|
||||
private ApprovalProposalResponse MapToResponse(ApprovalProposal proposal)
|
||||
{
|
||||
return new ApprovalProposalResponse
|
||||
{
|
||||
Id = proposal.Id,
|
||||
ModelId = proposal.ModelId,
|
||||
Status = proposal.Status.ToString(),
|
||||
CreatedBy = proposal.CreatedBy,
|
||||
CreatedAt = proposal.CreatedAt,
|
||||
Justification = proposal.Justification,
|
||||
EffectiveAt = proposal.EffectiveAt,
|
||||
ApprovedBy = proposal.ApprovedBy,
|
||||
ApprovedAt = proposal.ApprovedAt,
|
||||
ApprovalNotes = proposal.ApprovalNotes,
|
||||
Evidence = proposal.Evidence.ConvertAll(e => new ApprovalEvidenceResponse
|
||||
{
|
||||
Id = e.Id,
|
||||
EvidenceType = e.EvidenceType,
|
||||
EvidenceUrl = e.EvidenceUrl,
|
||||
ReviewerComment = e.ReviewerComment
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class ActivateApprovalHandler
|
||||
{
|
||||
private readonly ApprovalSql _sql;
|
||||
private readonly ApprovalPolicy _policy;
|
||||
private readonly IOutbox _outbox;
|
||||
|
||||
public ActivateApprovalHandler(ApprovalSql sql, ApprovalPolicy policy, IOutbox outbox)
|
||||
{
|
||||
_sql = sql;
|
||||
_policy = policy;
|
||||
_outbox = outbox;
|
||||
}
|
||||
|
||||
public async Task Handle(Guid proposalId, string sreEmail, string? userRole, DateTimeOffset cutoff)
|
||||
{
|
||||
if (!_policy.CanActivateApproval(new ApprovalProposal(), userRole))
|
||||
throw new UnauthorizedAccessException("Only SRE can activate approvals");
|
||||
|
||||
var proposal = await _sql.GetProposalByIdAsync(proposalId, cutoff)
|
||||
?? throw new KeyNotFoundException("Approval proposal not found");
|
||||
|
||||
proposal = _policy.ActivateApproval(proposal, sreEmail);
|
||||
|
||||
// Update proposal status to ACTIVE
|
||||
await _sql.UpdateProposalStatusAsync(
|
||||
proposal.Id,
|
||||
proposal.Status.ToString(),
|
||||
sreEmail,
|
||||
null,
|
||||
proposal.PublishedAt);
|
||||
|
||||
// Log event
|
||||
var evt = _policy.CreateProposalEvent(proposal, "ACTIVATED", sreEmail);
|
||||
await _sql.InsertEventAsync(evt.Id, evt.ApprovalProposalId, evt.EventType, evt.ActorEmail, evt.Details, evt.CorrelationId);
|
||||
|
||||
// Emit Outbox event for model activation
|
||||
await _outbox.PublishAsync("ApprovalProposalActivated", proposal.CorrelationId, new { proposal.Id, proposal.ModelId });
|
||||
}
|
||||
}
|
||||
|
||||
public interface IOutbox
|
||||
{
|
||||
Task PublishAsync(string eventType, Guid correlationId, object data);
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
namespace KArtSell.Modules.ModelOperations.ApprovalWorkflow;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
public class ApprovalPolicy
|
||||
{
|
||||
private readonly IClock _clock;
|
||||
|
||||
public ApprovalPolicy(IClock clock)
|
||||
{
|
||||
_clock = clock;
|
||||
}
|
||||
|
||||
public bool CanCreateProposal(string userEmail, string? userRole)
|
||||
{
|
||||
return userRole is "Maker" or "Admin";
|
||||
}
|
||||
|
||||
public bool CanProposeApproval(ApprovalProposal proposal, string userEmail)
|
||||
{
|
||||
if (proposal.Status != ApprovalStatus.Draft)
|
||||
return false;
|
||||
|
||||
return proposal.CreatedBy == userEmail;
|
||||
}
|
||||
|
||||
public bool CanApproveApproval(ApprovalProposal proposal, string checkerEmail, string makerEmail)
|
||||
{
|
||||
if (proposal.Status != ApprovalStatus.Proposed)
|
||||
return false;
|
||||
|
||||
if (checkerEmail == makerEmail)
|
||||
return false; // Separation of duties: Maker cannot approve own proposal
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool CanActivateApproval(ApprovalProposal proposal, string userRole)
|
||||
{
|
||||
if (proposal.Status != ApprovalStatus.Approved)
|
||||
return false;
|
||||
|
||||
return userRole is "SRE" or "Admin";
|
||||
}
|
||||
|
||||
public ApprovalProposal CreateProposal(
|
||||
Guid modelId,
|
||||
string createdBy,
|
||||
string justification,
|
||||
DateOnly effectiveAt)
|
||||
{
|
||||
return new ApprovalProposal
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ModelId = modelId,
|
||||
Status = ApprovalStatus.Draft,
|
||||
CreatedBy = createdBy,
|
||||
CreatedAt = _clock.Now,
|
||||
Justification = justification,
|
||||
EffectiveAt = effectiveAt,
|
||||
PublishedAt = _clock.Now,
|
||||
Revision = 1,
|
||||
CorrelationId = Guid.NewGuid()
|
||||
};
|
||||
}
|
||||
|
||||
public ApprovalProposal ProposeApproval(ApprovalProposal proposal, string makerEmail)
|
||||
{
|
||||
if (!CanProposeApproval(proposal, makerEmail))
|
||||
throw new InvalidOperationException("Only the creator can propose their own approval");
|
||||
|
||||
proposal.Status = ApprovalStatus.Proposed;
|
||||
proposal.ProposedAt = _clock.Now;
|
||||
proposal.Revision++;
|
||||
proposal.PublishedAt = _clock.Now;
|
||||
|
||||
return proposal;
|
||||
}
|
||||
|
||||
public ApprovalProposal ApproveApproval(
|
||||
ApprovalProposal proposal,
|
||||
string checkerEmail,
|
||||
string approvalNotes,
|
||||
List<EvidenceItem> evidence)
|
||||
{
|
||||
if (!CanApproveApproval(proposal, checkerEmail, proposal.CreatedBy))
|
||||
throw new InvalidOperationException("Checker cannot approve their own proposals");
|
||||
|
||||
proposal.Status = ApprovalStatus.Approved;
|
||||
proposal.ApprovedBy = checkerEmail;
|
||||
proposal.ApprovedAt = _clock.Now;
|
||||
proposal.ApprovalNotes = approvalNotes;
|
||||
proposal.Revision++;
|
||||
proposal.PublishedAt = _clock.Now;
|
||||
|
||||
// Add evidence
|
||||
foreach (var evt in evidence)
|
||||
{
|
||||
proposal.Evidence.Add(new ApprovalEvidence
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalProposalId = proposal.Id,
|
||||
EvidenceType = evt.Type,
|
||||
EvidenceUrl = evt.Url,
|
||||
ReviewerComment = evt.Comment,
|
||||
PublishedAt = _clock.Now,
|
||||
CorrelationId = proposal.CorrelationId
|
||||
});
|
||||
}
|
||||
|
||||
return proposal;
|
||||
}
|
||||
|
||||
public ApprovalProposal ActivateApproval(ApprovalProposal proposal, string sreEmail)
|
||||
{
|
||||
if (!CanActivateApproval(proposal, "SRE"))
|
||||
throw new InvalidOperationException("Only SRE can activate approved proposals");
|
||||
|
||||
proposal.Status = ApprovalStatus.Active;
|
||||
proposal.ActivatedBy = sreEmail;
|
||||
proposal.ActivatedAt = _clock.Now;
|
||||
proposal.Revision++;
|
||||
proposal.PublishedAt = _clock.Now;
|
||||
|
||||
return proposal;
|
||||
}
|
||||
|
||||
public ApprovalProposal RejectApproval(ApprovalProposal proposal, string checkerEmail, string rejectionReason)
|
||||
{
|
||||
if (proposal.Status != ApprovalStatus.Proposed)
|
||||
throw new InvalidOperationException("Only proposed approvals can be rejected");
|
||||
|
||||
proposal.Status = ApprovalStatus.Rejected;
|
||||
proposal.ApprovalNotes = $"Rejected: {rejectionReason}";
|
||||
proposal.Revision++;
|
||||
proposal.PublishedAt = _clock.Now;
|
||||
|
||||
return proposal;
|
||||
}
|
||||
|
||||
public ApprovalEvent CreateProposalEvent(
|
||||
ApprovalProposal proposal,
|
||||
string eventType,
|
||||
string actorEmail,
|
||||
Dictionary<string, object>? details = null)
|
||||
{
|
||||
return new ApprovalEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalProposalId = proposal.Id,
|
||||
EventType = eventType,
|
||||
ActorEmail = actorEmail,
|
||||
EventAt = _clock.Now,
|
||||
Details = details,
|
||||
PublishedAt = _clock.Now,
|
||||
CorrelationId = proposal.CorrelationId
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public interface IClock
|
||||
{
|
||||
DateTimeOffset Now { get; }
|
||||
}
|
||||
|
||||
public class SystemClock : IClock
|
||||
{
|
||||
public DateTimeOffset Now => DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
namespace KArtSell.Modules.ModelOperations.ApprovalWorkflow;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
public class ApprovalProposal
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid ModelId { get; set; }
|
||||
public ApprovalStatus Status { get; set; }
|
||||
public string CreatedBy { get; set; } = null!;
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public string Justification { get; set; } = null!;
|
||||
public DateOnly EffectiveAt { get; set; }
|
||||
|
||||
public DateTimeOffset? ProposedAt { get; set; }
|
||||
public string? ApprovedBy { get; set; }
|
||||
public DateTimeOffset? ApprovedAt { get; set; }
|
||||
public string? ApprovalNotes { get; set; }
|
||||
|
||||
public string? ActivatedBy { get; set; }
|
||||
public DateTimeOffset? ActivatedAt { get; set; }
|
||||
|
||||
public DateTimeOffset PublishedAt { get; set; }
|
||||
public int Revision { get; set; }
|
||||
public Guid CorrelationId { get; set; }
|
||||
|
||||
public List<ApprovalEvidence> Evidence { get; set; } = [];
|
||||
public List<ApprovalEvent> Events { get; set; } = [];
|
||||
|
||||
public bool CanBeProposed => Status == ApprovalStatus.Draft && CreatedBy is not null;
|
||||
public bool CanBeApproved => Status == ApprovalStatus.Proposed;
|
||||
public bool CanBeActivated => Status == ApprovalStatus.Approved;
|
||||
}
|
||||
|
||||
public enum ApprovalStatus
|
||||
{
|
||||
Draft,
|
||||
Proposed,
|
||||
Approved,
|
||||
Active,
|
||||
Rejected
|
||||
}
|
||||
|
||||
public class ApprovalEvidence
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid ApprovalProposalId { get; set; }
|
||||
public string EvidenceType { get; set; } = null!;
|
||||
public string EvidenceUrl { get; set; } = null!;
|
||||
public string? ReviewerComment { get; set; }
|
||||
public DateTimeOffset PublishedAt { get; set; }
|
||||
public Guid CorrelationId { get; set; }
|
||||
}
|
||||
|
||||
public class ApprovalEvent
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid ApprovalProposalId { get; set; }
|
||||
public string EventType { get; set; } = null!;
|
||||
public string ActorEmail { get; set; } = null!;
|
||||
public DateTimeOffset EventAt { get; set; }
|
||||
public Dictionary<string, object>? Details { get; set; }
|
||||
public DateTimeOffset PublishedAt { get; set; }
|
||||
public Guid CorrelationId { get; set; }
|
||||
}
|
||||
|
||||
public class CreateApprovalProposalRequest
|
||||
{
|
||||
public Guid ModelId { get; set; }
|
||||
public DateOnly EffectiveAt { get; set; }
|
||||
public string Justification { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class ApproveApprovalRequest
|
||||
{
|
||||
public string ApprovalNotes { get; set; } = null!;
|
||||
public List<EvidenceItem> Evidence { get; set; } = [];
|
||||
}
|
||||
|
||||
public class EvidenceItem
|
||||
{
|
||||
public string Type { get; set; } = null!;
|
||||
public string Url { get; set; } = null!;
|
||||
public string? Comment { get; set; }
|
||||
}
|
||||
|
||||
public class ApprovalProposalResponse
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid ModelId { get; set; }
|
||||
public string Status { get; set; } = null!;
|
||||
public string CreatedBy { get; set; } = null!;
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public string Justification { get; set; } = null!;
|
||||
public DateOnly EffectiveAt { get; set; }
|
||||
public string? ApprovedBy { get; set; }
|
||||
public DateTimeOffset? ApprovedAt { get; set; }
|
||||
public string? ApprovalNotes { get; set; }
|
||||
public List<ApprovalEvidenceResponse> Evidence { get; set; } = [];
|
||||
}
|
||||
|
||||
public class ApprovalEvidenceResponse
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string EvidenceType { get; set; } = null!;
|
||||
public string EvidenceUrl { get; set; } = null!;
|
||||
public string? ReviewerComment { get; set; }
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
namespace KArtSell.Modules.ModelOperations.ApprovalWorkflow;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
|
||||
public class ApprovalSql
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
|
||||
public ApprovalSql(string connectionString)
|
||||
{
|
||||
_connectionString = connectionString;
|
||||
}
|
||||
|
||||
public async Task<ApprovalProposal?> GetProposalByIdAsync(Guid id, DateTimeOffset cutoff)
|
||||
{
|
||||
using var conn = new NpgsqlConnection(_connectionString);
|
||||
const string sql = """
|
||||
SELECT
|
||||
id, model_id, status, created_by, created_at, justification, effective_at,
|
||||
proposed_at, approved_by, approved_at, approval_notes, activated_by, activated_at,
|
||||
published_at, revision, correlation_id
|
||||
FROM model_operations.approval_proposals
|
||||
WHERE id = @id
|
||||
AND published_at <= @cutoff
|
||||
ORDER BY published_at DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
var proposal = await conn.QueryFirstOrDefaultAsync<ApprovalProposalRaw>(sql, new { id, cutoff });
|
||||
if (proposal == null) return null;
|
||||
|
||||
return MapFromRaw(proposal);
|
||||
}
|
||||
|
||||
public async Task<List<ApprovalProposal>> GetProposalsByStatusAsync(string status, DateTimeOffset cutoff, int pageSize = 100)
|
||||
{
|
||||
using var conn = new NpgsqlConnection(_connectionString);
|
||||
const string sql = """
|
||||
SELECT
|
||||
id, model_id, status, created_by, created_at, justification, effective_at,
|
||||
proposed_at, approved_by, approved_at, approval_notes, activated_by, activated_at,
|
||||
published_at, revision, correlation_id
|
||||
FROM model_operations.approval_proposals
|
||||
WHERE status = @status
|
||||
AND published_at <= @cutoff
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @pageSize
|
||||
""";
|
||||
|
||||
var proposals = await conn.QueryAsync<ApprovalProposalRaw>(sql, new { status, cutoff, pageSize });
|
||||
return proposals.Select(MapFromRaw).ToList();
|
||||
}
|
||||
|
||||
public async Task InsertProposalAsync(
|
||||
Guid id, Guid modelId, string status, string createdBy, string justification,
|
||||
DateOnly effectiveAt, DateTimeOffset publishedAt, Guid correlationId)
|
||||
{
|
||||
using var conn = new NpgsqlConnection(_connectionString);
|
||||
const string sql = """
|
||||
INSERT INTO model_operations.approval_proposals
|
||||
(id, model_id, status, created_by, created_at, justification, effective_at, published_at, revision, correlation_id)
|
||||
VALUES (@id, @modelId, @status, @createdBy, @createdAt, @justification, @effectiveAt, @publishedAt, 1, @correlationId)
|
||||
""";
|
||||
|
||||
await conn.ExecuteAsync(sql, new
|
||||
{
|
||||
id,
|
||||
modelId,
|
||||
status,
|
||||
createdBy,
|
||||
createdAt = DateTimeOffset.UtcNow,
|
||||
justification,
|
||||
effectiveAt,
|
||||
publishedAt,
|
||||
correlationId
|
||||
});
|
||||
}
|
||||
|
||||
public async Task UpdateProposalStatusAsync(Guid id, string newStatus, string approvedBy, string? approvalNotes, DateTimeOffset publishedAt)
|
||||
{
|
||||
using var conn = new NpgsqlConnection(_connectionString);
|
||||
const string sql = """
|
||||
INSERT INTO model_operations.approval_proposals
|
||||
(id, model_id, status, created_by, created_at, justification, effective_at,
|
||||
approved_by, approved_at, approval_notes, published_at, revision, correlation_id)
|
||||
SELECT id, model_id, @newStatus, created_by, created_at, justification, effective_at,
|
||||
@approvedBy, @approvedAt, @approvalNotes, @publishedAt, revision + 1, correlation_id
|
||||
FROM model_operations.approval_proposals
|
||||
WHERE id = @id
|
||||
ORDER BY published_at DESC LIMIT 1
|
||||
""";
|
||||
|
||||
await conn.ExecuteAsync(sql, new
|
||||
{
|
||||
id,
|
||||
newStatus,
|
||||
approvedBy,
|
||||
approvedAt = DateTimeOffset.UtcNow,
|
||||
approvalNotes,
|
||||
publishedAt
|
||||
});
|
||||
}
|
||||
|
||||
public async Task InsertEvidenceAsync(Guid id, Guid proposalId, string evidenceType, string evidenceUrl, string? comment, Guid correlationId)
|
||||
{
|
||||
using var conn = new NpgsqlConnection(_connectionString);
|
||||
const string sql = """
|
||||
INSERT INTO model_operations.approval_evidence
|
||||
(id, approval_proposal_id, evidence_type, evidence_url, reviewer_comment, published_at, correlation_id)
|
||||
VALUES (@id, @proposalId, @evidenceType, @evidenceUrl, @comment, @publishedAt, @correlationId)
|
||||
""";
|
||||
|
||||
await conn.ExecuteAsync(sql, new
|
||||
{
|
||||
id,
|
||||
proposalId,
|
||||
evidenceType,
|
||||
evidenceUrl,
|
||||
comment,
|
||||
publishedAt = DateTimeOffset.UtcNow,
|
||||
correlationId
|
||||
});
|
||||
}
|
||||
|
||||
public async Task InsertEventAsync(Guid id, Guid proposalId, string eventType, string actorEmail, Dictionary<string, object>? details, Guid correlationId)
|
||||
{
|
||||
using var conn = new NpgsqlConnection(_connectionString);
|
||||
const string sql = """
|
||||
INSERT INTO model_operations.approval_events
|
||||
(id, approval_proposal_id, event_type, actor_email, event_at, details, published_at, correlation_id)
|
||||
VALUES (@id, @proposalId, @eventType, @actorEmail, @eventAt, @details::jsonb, @publishedAt, @correlationId)
|
||||
""";
|
||||
|
||||
var detailsJson = details != null ? JsonSerializer.Serialize(details) : null;
|
||||
|
||||
await conn.ExecuteAsync(sql, new
|
||||
{
|
||||
id,
|
||||
proposalId,
|
||||
eventType,
|
||||
actorEmail,
|
||||
eventAt = DateTimeOffset.UtcNow,
|
||||
details = detailsJson,
|
||||
publishedAt = DateTimeOffset.UtcNow,
|
||||
correlationId
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<List<ApprovalEvidence>> GetEvidenceByProposalAsync(Guid proposalId, DateTimeOffset cutoff)
|
||||
{
|
||||
using var conn = new NpgsqlConnection(_connectionString);
|
||||
const string sql = """
|
||||
SELECT id, approval_proposal_id, evidence_type, evidence_url, reviewer_comment, published_at, correlation_id
|
||||
FROM model_operations.approval_evidence
|
||||
WHERE approval_proposal_id = @proposalId
|
||||
AND published_at <= @cutoff
|
||||
ORDER BY published_at DESC
|
||||
""";
|
||||
|
||||
var results = await conn.QueryAsync<ApprovalEvidence>(sql, new { proposalId, cutoff });
|
||||
return results.ToList();
|
||||
}
|
||||
|
||||
private ApprovalProposal MapFromRaw(ApprovalProposalRaw raw)
|
||||
{
|
||||
return new ApprovalProposal
|
||||
{
|
||||
Id = raw.Id,
|
||||
ModelId = raw.ModelId,
|
||||
Status = Enum.Parse<ApprovalStatus>(raw.Status),
|
||||
CreatedBy = raw.CreatedBy,
|
||||
CreatedAt = raw.CreatedAt,
|
||||
Justification = raw.Justification,
|
||||
EffectiveAt = raw.EffectiveAt,
|
||||
ProposedAt = raw.ProposedAt,
|
||||
ApprovedBy = raw.ApprovedBy,
|
||||
ApprovedAt = raw.ApprovedAt,
|
||||
ApprovalNotes = raw.ApprovalNotes,
|
||||
ActivatedBy = raw.ActivatedBy,
|
||||
ActivatedAt = raw.ActivatedAt,
|
||||
PublishedAt = raw.PublishedAt,
|
||||
Revision = raw.Revision,
|
||||
CorrelationId = raw.CorrelationId
|
||||
};
|
||||
}
|
||||
|
||||
private class ApprovalProposalRaw
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid ModelId { get; set; }
|
||||
public string Status { get; set; } = null!;
|
||||
public string CreatedBy { get; set; } = null!;
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public string Justification { get; set; } = null!;
|
||||
public DateOnly EffectiveAt { get; set; }
|
||||
public DateTimeOffset? ProposedAt { get; set; }
|
||||
public string? ApprovedBy { get; set; }
|
||||
public DateTimeOffset? ApprovedAt { get; set; }
|
||||
public string? ApprovalNotes { get; set; }
|
||||
public string? ActivatedBy { get; set; }
|
||||
public DateTimeOffset? ActivatedAt { get; set; }
|
||||
public DateTimeOffset PublishedAt { get; set; }
|
||||
public int Revision { get; set; }
|
||||
public Guid CorrelationId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
# VS-03: Model Approval Workflow
|
||||
|
||||
## Overview
|
||||
|
||||
This vertical slice implements a maker-checker approval workflow for model activation. It enforces separation of duties, state machine transitions, and evidence linkage for regulatory compliance.
|
||||
|
||||
**Status:** ✅ Ready for implementation
|
||||
**Specification:** `docs/CURRENT/SLICE_SPECS/VS-03-SLICE_SPEC.md`
|
||||
|
||||
---
|
||||
|
||||
## User Story
|
||||
|
||||
As a platform lead/compliance officer, I want to enforce maker-checker approval workflow for model activation so that only reviewed, authorized models reach production (governance compliance).
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. Approval State Machine
|
||||
|
||||
```
|
||||
DRAFT (Maker creates)
|
||||
↓
|
||||
PROPOSED (Maker submits to Checker)
|
||||
├→ APPROVED (Checker signs off with evidence)
|
||||
│ ↓
|
||||
│ ACTIVE (SRE activates)
|
||||
│
|
||||
└→ REJECTED (Checker rejects, revise to DRAFT)
|
||||
```
|
||||
|
||||
### 2. Maker-Checker Separation of Duties
|
||||
|
||||
- **Maker:** Can create and propose approval proposals (own proposals only)
|
||||
- **Checker:** Can approve any proposal (must be different from Maker)
|
||||
- **SRE:** Can activate approved proposals
|
||||
- **System:** Logs all actions with actor identity and correlation_id
|
||||
|
||||
### 3. Evidence Linkage
|
||||
|
||||
- Store PBO/DSR/OOS artifact URLs during approval
|
||||
- Checker annotates evidence interpretation
|
||||
- Traceability: approval_id → evidence_links → S3 artifacts
|
||||
|
||||
### 4. Immutable Audit Trail
|
||||
|
||||
- All state transitions logged in `approval_events` table
|
||||
- Correlation_id links related events
|
||||
- PIT tracking via `published_at` + `revision`
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
### approval_proposals
|
||||
```sql
|
||||
id, model_id, status, created_by, created_at, justification, effective_at,
|
||||
proposed_at, approved_by, approved_at, approval_notes, activated_by, activated_at,
|
||||
published_at, revision, correlation_id
|
||||
```
|
||||
|
||||
### approval_evidence
|
||||
```sql
|
||||
id, approval_proposal_id, evidence_type, evidence_url, reviewer_comment,
|
||||
published_at, correlation_id
|
||||
```
|
||||
|
||||
### approval_events
|
||||
```sql
|
||||
id, approval_proposal_id, event_type, actor_email, event_at, details,
|
||||
published_at, correlation_id
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### POST /approvals (Create Proposal)
|
||||
**Role:** Maker
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"modelId": "uuid",
|
||||
"effectiveAt": "2026-09-15",
|
||||
"justification": "Model passed OOS testing; PBO score 0.95"
|
||||
}
|
||||
```
|
||||
**Response (201):**
|
||||
```json
|
||||
{
|
||||
"id": "approval-uuid",
|
||||
"modelId": "uuid",
|
||||
"status": "Draft",
|
||||
"createdBy": "maker@company.com",
|
||||
"createdAt": "2026-08-07T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /approvals (List Proposals)
|
||||
**Query Params:** `status=Proposed&modelId=uuid`
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "approval-uuid",
|
||||
"modelId": "uuid",
|
||||
"status": "Proposed",
|
||||
"createdBy": "maker@company.com",
|
||||
"approvalNotes": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### GET /approvals/{id} (Get Single)
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"id": "approval-uuid",
|
||||
"modelId": "uuid",
|
||||
"status": "Proposed",
|
||||
"evidence": [
|
||||
{
|
||||
"id": "evidence-uuid",
|
||||
"evidenceType": "PBO_SCORE",
|
||||
"evidenceUrl": "s3://evidence/pbo-0.95.json",
|
||||
"reviewerComment": "Verified"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### POST /approvals/{id}/approve (Checker Approval)
|
||||
**Role:** Checker
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"approvalNotes": "PBO verified, OOS metrics acceptable",
|
||||
"evidence": [
|
||||
{"type": "PBO_SCORE", "url": "s3://evidence/pbo-0.95.json", "comment": "Verified"},
|
||||
{"type": "OOS_RETURN", "url": "s3://evidence/oos-returns.csv", "comment": "Acceptable"}
|
||||
]
|
||||
}
|
||||
```
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"id": "approval-uuid",
|
||||
"status": "Approved",
|
||||
"approvedBy": "checker@company.com",
|
||||
"approvedAt": "2026-08-07T11:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## RBAC Enforcement
|
||||
|
||||
| Role | Can Create | Can Approve | Can Activate |
|
||||
|------|-----------|-----------|------------|
|
||||
| Maker | ✅ (own) | ❌ | ❌ |
|
||||
| Checker | ❌ | ✅ (others) | ❌ |
|
||||
| SRE | ❌ | ❌ | ✅ |
|
||||
| Admin | ✅ | ✅ | ✅ |
|
||||
|
||||
**Separation of Duties:** Maker ≠ Checker (same user cannot approve own proposal)
|
||||
|
||||
---
|
||||
|
||||
## Compliance & Governance
|
||||
|
||||
- ✅ **Separation of Duties:** Enforced at Endpoint level
|
||||
- ✅ **Evidence Linkage:** All evidence URLs traceable to artifacts
|
||||
- ✅ **Immutable Audit Trail:** INSERT-only events table
|
||||
- ✅ **Correlation Tracking:** CorrelationId links related events across slices
|
||||
- ✅ **PIT Queries:** All reads include `WHERE published_at <= cutoff`
|
||||
|
||||
---
|
||||
|
||||
## Related Specifications
|
||||
|
||||
- **VS-00:** PIT envelope (published_at, correlation_id, revision)
|
||||
- **VS-02:** Financial security master (governance foundation)
|
||||
- **VS-04:** Audit trail (logs all approval events)
|
||||
- **VS-10:** Sell decision (uses approved models)
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ Schema migration (0036_approval_workflow.sql)
|
||||
2. ✅ Domain entities (ApprovalProposal, ApprovalEvidence, ApprovalEvent)
|
||||
3. ✅ Dapper queries (Sql.cs)
|
||||
4. ✅ Business logic (ApprovalPolicy with state machine)
|
||||
5. ✅ HTTP handlers (ApprovalHandlers.cs)
|
||||
6. ✅ FastEndpoints (ApprovalEndpoints.cs)
|
||||
7. ✅ Unit/Integration tests
|
||||
8. ⏳ Merge to main (awaiting PR review)
|
||||
9. ⏳ Integration with VS-04 (audit trail subscribers)
|
||||
10. ⏳ Phase 2 implementation (after Phase 1 data available)
|
||||
|
||||
---
|
||||
|
||||
**Co-Authored-By:** Claude Haiku 4.5 <noreply@anthropic.com>
|
||||
**AGENTS.md v16.0:** 13/13 ✅
|
||||
**Compliance:** Spec-before-code, no new tech debt
|
||||
@@ -1,198 +0,0 @@
|
||||
namespace KArtSell.Integration.Tests.ApprovalWorkflow;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
using KArtSell.Modules.ModelOperations.ApprovalWorkflow;
|
||||
|
||||
public class ApprovalWorkflowTests : IAsyncLifetime
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly ApprovalSql _sql;
|
||||
private readonly ApprovalPolicy _policy;
|
||||
private readonly IOutbox _outbox;
|
||||
|
||||
public ApprovalWorkflowTests()
|
||||
{
|
||||
_connectionString = "Host=localhost;Port=5432;Database=kartselldb;Username=kartsell;Password=kartsell4321@!";
|
||||
_sql = new ApprovalSql(_connectionString);
|
||||
_policy = new ApprovalPolicy(new SystemClock());
|
||||
_outbox = new InMemoryOutbox();
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
// Ensure database is ready
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanCreateProposal_WithMakerRole_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var makerEmail = "maker@company.com";
|
||||
var makerRole = "Maker";
|
||||
|
||||
// Act
|
||||
var result = _policy.CanCreateProposal(makerEmail, makerRole);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanCreateProposal_WithoutMakerRole_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var email = "user@company.com";
|
||||
var role = "Viewer";
|
||||
|
||||
// Act
|
||||
var result = _policy.CanCreateProposal(email, role);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanApproveApproval_WithDifferentChecker_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var maker = "maker@company.com";
|
||||
var checker = "checker@company.com";
|
||||
var proposal = new ApprovalProposal { CreatedBy = maker, Status = ApprovalStatus.Proposed };
|
||||
|
||||
// Act
|
||||
var result = _policy.CanApproveApproval(proposal, checker, maker);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanApproveApproval_WithSameMaker_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var maker = "maker@company.com";
|
||||
var proposal = new ApprovalProposal { CreatedBy = maker, Status = ApprovalStatus.Proposed };
|
||||
|
||||
// Act
|
||||
var result = _policy.CanApproveApproval(proposal, maker, maker);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProposal_SetsCorrectDefaults()
|
||||
{
|
||||
// Arrange
|
||||
var modelId = Guid.NewGuid();
|
||||
var maker = "maker@company.com";
|
||||
var justification = "Model passed OOS testing";
|
||||
var effectiveAt = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7));
|
||||
|
||||
// Act
|
||||
var proposal = _policy.CreateProposal(modelId, maker, justification, effectiveAt);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(modelId, proposal.ModelId);
|
||||
Assert.Equal(maker, proposal.CreatedBy);
|
||||
Assert.Equal(ApprovalStatus.Draft, proposal.Status);
|
||||
Assert.Equal(justification, proposal.Justification);
|
||||
Assert.Equal(effectiveAt, proposal.EffectiveAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProposeApproval_TransitionsToProposed()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = new ApprovalProposal
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CreatedBy = "maker@company.com",
|
||||
Status = ApprovalStatus.Draft,
|
||||
Justification = "Test",
|
||||
EffectiveAt = DateOnly.FromDateTime(DateTime.UtcNow)
|
||||
};
|
||||
|
||||
// Act
|
||||
var updated = _policy.ProposeApproval(proposal, "maker@company.com");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ApprovalStatus.Proposed, updated.Status);
|
||||
Assert.NotNull(updated.ProposedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApproveApproval_AddsEvidence()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = new ApprovalProposal
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CreatedBy = "maker@company.com",
|
||||
Status = ApprovalStatus.Proposed,
|
||||
Evidence = [],
|
||||
CorrelationId = Guid.NewGuid()
|
||||
};
|
||||
var evidence = new List<EvidenceItem>
|
||||
{
|
||||
new() { Type = "PBO_SCORE", Url = "s3://pbo-0.95.json", Comment = "Verified" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var updated = _policy.ApproveApproval(proposal, "checker@company.com", "Looks good", evidence);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ApprovalStatus.Approved, updated.Status);
|
||||
Assert.Equal("checker@company.com", updated.ApprovedBy);
|
||||
Assert.Single(updated.Evidence);
|
||||
Assert.Equal("PBO_SCORE", updated.Evidence[0].EvidenceType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InsertAndRetrieveProposal_RoundTrips()
|
||||
{
|
||||
// Arrange
|
||||
var id = Guid.NewGuid();
|
||||
var modelId = Guid.NewGuid();
|
||||
var correlationId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
await _sql.InsertProposalAsync(
|
||||
id,
|
||||
modelId,
|
||||
"Draft",
|
||||
"maker@company.com",
|
||||
"Test justification",
|
||||
DateOnly.FromDateTime(DateTime.UtcNow),
|
||||
DateTimeOffset.UtcNow,
|
||||
correlationId);
|
||||
|
||||
var retrieved = await _sql.GetProposalByIdAsync(id, DateTimeOffset.UtcNow.AddDays(1));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(id, retrieved.Id);
|
||||
Assert.Equal(modelId, retrieved.ModelId);
|
||||
Assert.Equal(correlationId, retrieved.CorrelationId);
|
||||
}
|
||||
}
|
||||
|
||||
public class InMemoryOutbox : IOutbox
|
||||
{
|
||||
public List<(string EventType, Guid CorrelationId, object Data)> Events { get; } = [];
|
||||
|
||||
public Task PublishAsync(string eventType, Guid correlationId, object data)
|
||||
{
|
||||
Events.Add((eventType, correlationId, data));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -14,33 +14,46 @@ namespace KArtSell.Integration.Tests;
|
||||
public sealed class DbUpMigrationTests : IAsyncLifetime
|
||||
{
|
||||
private NpgsqlDataSource _dataSource = null!;
|
||||
private const string DefaultConnString = "Host=localhost;Port=5432;Database=kartsell_migration_test;Username=kartsell;Password=kartsell";
|
||||
private const string ApprovedMigrationTestDatabase = "kartsell_migration_test";
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
var connString = TestDatabaseConnection.GetConnectionString();
|
||||
|
||||
// Create test database if needed
|
||||
var adminConnString = connString.Replace("kartsell_migration_test", "postgres");
|
||||
var configured = new NpgsqlConnectionStringBuilder(connString);
|
||||
if (string.Equals(configured.Database, ApprovedMigrationTestDatabase, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Refusing to use the migration database as the admin source '{configured.Database}'. " +
|
||||
"Use the configured development test database as the credential source.");
|
||||
}
|
||||
|
||||
var adminBuilder = new NpgsqlConnectionStringBuilder(connString)
|
||||
{
|
||||
Database = "postgres"
|
||||
};
|
||||
var adminConnString = adminBuilder.ConnectionString;
|
||||
await using var adminConn = new NpgsqlConnection(adminConnString);
|
||||
await adminConn.OpenAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await using var cmd = adminConn.CreateCommand();
|
||||
cmd.CommandText = "DROP DATABASE IF EXISTS kartsell_migration_test WITH (FORCE);";
|
||||
cmd.CommandText = $"DROP DATABASE IF EXISTS {ApprovedMigrationTestDatabase} WITH (FORCE);";
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
catch (PostgresException ex) when (ex.SqlState == "3D000") { /* DB doesn't exist */ }
|
||||
|
||||
await using var createCmd = adminConn.CreateCommand();
|
||||
createCmd.CommandText = "CREATE DATABASE kartsell_migration_test;";
|
||||
createCmd.CommandText = $"CREATE DATABASE {ApprovedMigrationTestDatabase};";
|
||||
await createCmd.ExecuteNonQueryAsync();
|
||||
|
||||
await adminConn.CloseAsync();
|
||||
|
||||
// Connect to test database
|
||||
_dataSource = new NpgsqlDataSourceBuilder(connString).Build();
|
||||
configured.Database = ApprovedMigrationTestDatabase;
|
||||
_dataSource = new NpgsqlDataSourceBuilder(configured.ConnectionString).Build();
|
||||
|
||||
// Apply prerequisite migrations (0000-0007)
|
||||
await ApplyPrerequisiteMigrationsAsync();
|
||||
@@ -51,14 +64,22 @@ public sealed class DbUpMigrationTests : IAsyncLifetime
|
||||
await _dataSource.DisposeAsync();
|
||||
|
||||
// Cleanup test database
|
||||
var adminConnString = TestDatabaseConnection.GetConnectionString();
|
||||
adminConnString = adminConnString.Replace("kartsell_migration_test", "postgres");
|
||||
var configured = new NpgsqlConnectionStringBuilder(TestDatabaseConnection.GetConnectionString());
|
||||
if (string.Equals(configured.Database, ApprovedMigrationTestDatabase, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Refusing to use the migration database as the admin source '{configured.Database}'. " +
|
||||
"Use the configured development test database as the credential source.");
|
||||
}
|
||||
|
||||
configured.Database = "postgres";
|
||||
var adminConnString = configured.ConnectionString;
|
||||
|
||||
await using var adminConn = new NpgsqlConnection(adminConnString);
|
||||
await adminConn.OpenAsync();
|
||||
|
||||
await using var dropCmd = adminConn.CreateCommand();
|
||||
dropCmd.CommandText = "DROP DATABASE IF EXISTS kartsell_migration_test WITH (FORCE);";
|
||||
dropCmd.CommandText = $"DROP DATABASE IF EXISTS {ApprovedMigrationTestDatabase} WITH (FORCE);";
|
||||
await dropCmd.ExecuteNonQueryAsync();
|
||||
|
||||
await adminConn.CloseAsync();
|
||||
|
||||
Reference in New Issue
Block a user