From 3e1b5719068dba4141dad7c1a04743872bd843cd Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Sun, 28 Jun 2026 10:17:41 +0900 Subject: [PATCH] =?UTF-8?q?feat(bat-021):=20REQ-047=20Step2=20=EB=8F=99?= =?UTF-8?q?=EB=B0=98=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95=20+=20?= =?UTF-8?q?=EC=8B=A4=20DB=20=ED=86=B5=ED=95=A9=EA=B2=80=EC=A6=9D=20+=20?= =?UTF-8?q?=EB=9F=B0=EB=B6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ensure_schema() broker_credentials: daemon_id NOT NULL, PK=(daemon_id,broker)로 갱신 - _row_to_broker_credential: Step2 컬럼 순서(daemon_id 선두)로 갱신 - upsert_broker_credential: ON CONFLICT (daemon_id,broker)로 수정, fallback try/except 제거 - list_broker_credentials: fallback 제거, SELECT 컬럼 순서 갱신 - test_bat020_broker_credential_domain.py: AC-3/AC-4 행 순서를 Step2 포맷으로 갱신 - tests/test_bat021_step2_ensure_schema_and_upsert.py: 12개 신규 테스트 - docs/runbooks/bat-021-req047-step2-migration-runbook.md: 운영 적용 런북 - docs/batches/bat-021: batch 문서(index/planning/design/implementation/verification) 실 PostgreSQL 통합검증(AC-1~7 전건 PASS): Step1 upgrade → backfill → Step2 upgrade → downgrade 시나리오 포함 NULL 사전검증(upgrade 거부) 및 downgrade 안전장치(중복 시 RuntimeError) 확인 521 passed, 0 failed (기존 509 + 신규 12) --- app/repositories/postgres.py | 117 ++++---- .../design.md | 135 ++++++++++ .../implementation.md | 60 +++++ .../index.md | 28 ++ .../planning.md | 34 +++ .../verification.md | 164 ++++++++++++ docs/batches/index.md | 3 +- .../bat-021-req047-step2-migration-runbook.md | 193 ++++++++++++++ tests/test_bat020_broker_credential_domain.py | 29 +- ...t_bat021_step2_ensure_schema_and_upsert.py | 252 ++++++++++++++++++ 10 files changed, 931 insertions(+), 84 deletions(-) create mode 100644 docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/design.md create mode 100644 docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/implementation.md create mode 100644 docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/index.md create mode 100644 docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/planning.md create mode 100644 docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/verification.md create mode 100644 docs/runbooks/bat-021-req047-step2-migration-runbook.md create mode 100644 tests/test_bat021_step2_ensure_schema_and_upsert.py diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index e250e76..668d519 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -406,16 +406,20 @@ class PostgresPortfolioRepository: ON daemons (owner_user_id) """ ) + # NOTE(REQ-047 Step2 / BAT-021): daemon-scoped PK 전환 이후 최종 스키마. + # 이 CREATE TABLE IF NOT EXISTS는 Step2 적용 환경에서 테이블이 이미 존재해 스킵된다. + # Step2 미적용 환경에서는 alembic revision(Step1→Step2)을 먼저 적용해야 한다. cur.execute( """ CREATE TABLE IF NOT EXISTS broker_credentials ( - user_id TEXT NOT NULL, + daemon_id TEXT NOT NULL, broker TEXT NOT NULL, + user_id TEXT NOT NULL, account_ref TEXT NOT NULL, encrypted_payload TEXT NOT NULL, credential_version INTEGER NOT NULL DEFAULT 1, last_rotated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - PRIMARY KEY (user_id, broker) + PRIMARY KEY (daemon_id, broker) ) """ ) @@ -465,16 +469,17 @@ class PostgresPortfolioRepository: @staticmethod def _row_to_broker_credential(row: Any) -> BrokerCredential: + """Step2(REQ-047) 이후 컬럼 순서: daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at.""" if row is None: raise ValueError("브로커 인증 정보를 찾을 수 없습니다") return BrokerCredential( - user_id=row[0], - broker=row[1], - account_ref=row[2], - encrypted_payload=row[3], - credential_version=int(row[4]) if len(row) > 4 else 1, - last_rotated_at=row[5] if len(row) > 5 else None, - daemon_id=row[6] if len(row) > 6 else None, + daemon_id=row[0], + user_id=row[1], + broker=row[2], + account_ref=row[3], + encrypted_payload=row[4], + credential_version=int(row[5]) if len(row) > 5 else 1, + last_rotated_at=row[6] if len(row) > 6 else None, ) @staticmethod @@ -747,40 +752,23 @@ class PostgresPortfolioRepository: cur.execute("SELECT 1 FROM users WHERE id = %s", (user_id,)) if cur.fetchone() is None: raise ValueError("사용자를 찾을 수 없습니다") - # NOTE(REQ-046): daemon_id 컬럼은 REQ-047 Step1 마이그레이션 이후 존재. - # Step1 적용 전 환경에서는 daemon_id 컬럼이 없어 INSERT 실패 가능. - # Step2(PK 전환) 이후에는 ON CONFLICT 절도 (daemon_id, broker)로 변경 필요. - # 현재는 기존 PK (user_id, broker) ON CONFLICT 유지하고 daemon_id를 컬럼에 포함. - try: - cur.execute( - """ - INSERT INTO broker_credentials (user_id, broker, account_ref, encrypted_payload, daemon_id) - VALUES (%s, %s, %s, %s, %s) - ON CONFLICT (user_id, broker) DO UPDATE - SET account_ref = EXCLUDED.account_ref, - encrypted_payload = EXCLUDED.encrypted_payload, - daemon_id = EXCLUDED.daemon_id, - credential_version = broker_credentials.credential_version + 1, - last_rotated_at = NOW() - RETURNING user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at, daemon_id - """, - (user_id, broker, account_ref, encrypted_payload, daemon_id), - ) - except Exception: - # Fallback: daemon_id 컬럼이 아직 없는 환경(Step1 미적용) - cur.execute( - """ - INSERT INTO broker_credentials (user_id, broker, account_ref, encrypted_payload) - VALUES (%s, %s, %s, %s) - ON CONFLICT (user_id, broker) DO UPDATE - SET account_ref = EXCLUDED.account_ref, - encrypted_payload = EXCLUDED.encrypted_payload, - credential_version = broker_credentials.credential_version + 1, - last_rotated_at = NOW() - RETURNING user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at - """, - (user_id, broker, account_ref, encrypted_payload), - ) + # NOTE(REQ-047 Step2 / BAT-021): PK 전환 이후 ON CONFLICT (daemon_id, broker). + # daemon_id는 NOT NULL(Step2 제약). Step2 미적용 환경에서는 이 코드를 배포하지 마라. + # 배포 순서: Step1 alembic upgrade → backfill CLI → Step2 alembic upgrade → 코드 배포. + cur.execute( + """ + INSERT INTO broker_credentials (daemon_id, user_id, broker, account_ref, encrypted_payload) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (daemon_id, broker) DO UPDATE + SET account_ref = EXCLUDED.account_ref, + encrypted_payload = EXCLUDED.encrypted_payload, + user_id = EXCLUDED.user_id, + credential_version = broker_credentials.credential_version + 1, + last_rotated_at = NOW() + RETURNING daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at + """, + (daemon_id, user_id, broker, account_ref, encrypted_payload), + ) row = cur.fetchone() return self._row_to_broker_credential(row) @@ -790,43 +778,30 @@ class PostgresPortfolioRepository: daemon_id: str | None = None, ) -> list[BrokerCredential]: self.ensure_schema() + # NOTE(REQ-047 Step2 / BAT-021): daemon_id 컬럼은 Step2 이후 항상 존재(NOT NULL). + # Step2 미적용 환경에서는 이 코드를 배포하지 마라. with self._connect().cursor() as cur: - # NOTE(REQ-046): daemon_id 컬럼은 REQ-047 Step1 이후 존재. - # daemon_id 컬럼이 없는 환경(Step1 미적용)에서는 6컬럼 조회 fallback. - try: - if daemon_id is not None: - cur.execute( - """ - SELECT user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at, daemon_id - FROM broker_credentials - WHERE user_id = %s AND daemon_id = %s - ORDER BY broker ASC - """, - (user_id, daemon_id), - ) - else: - cur.execute( - """ - SELECT user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at, daemon_id - FROM broker_credentials - WHERE user_id = %s - ORDER BY broker ASC - """, - (user_id,), - ) - rows = cur.fetchall() - except Exception: - # Fallback: daemon_id 컬럼이 아직 없는 환경(Step1 미적용) + if daemon_id is not None: cur.execute( """ - SELECT user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at + SELECT daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at + FROM broker_credentials + WHERE user_id = %s AND daemon_id = %s + ORDER BY broker ASC + """, + (user_id, daemon_id), + ) + else: + cur.execute( + """ + SELECT daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at FROM broker_credentials WHERE user_id = %s ORDER BY broker ASC """, (user_id,), ) - rows = cur.fetchall() + rows = cur.fetchall() return [self._row_to_broker_credential(row) for row in rows] def update_broker_credential_daemon_id( diff --git a/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/design.md b/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/design.md new file mode 100644 index 0000000..c4103ef --- /dev/null +++ b/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/design.md @@ -0,0 +1,135 @@ +# BAT-021 Design + +## 변경 설계 + +### T-1/T-2/T-3: `app/repositories/postgres.py` 수정 + +#### ensure_schema() broker_credentials (T-1) + +현재 `ensure_schema()`의 `broker_credentials` CREATE TABLE은: +```sql +CREATE TABLE IF NOT EXISTS broker_credentials ( + user_id TEXT NOT NULL, + broker TEXT NOT NULL, + account_ref TEXT NOT NULL, + encrypted_payload TEXT NOT NULL, + credential_version INTEGER NOT NULL DEFAULT 1, + last_rotated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, broker) +) +``` + +Step2 이후 최종 스키마: +- `daemon_id TEXT NOT NULL` 추가 +- `PK = (daemon_id, broker)` +- `user_id` 컬럼 유지 (NOT NULL — 신규 INSERT는 항상 user_id 포함) + +갱신 후: +```sql +CREATE TABLE IF NOT EXISTS broker_credentials ( + daemon_id TEXT NOT NULL, + broker TEXT NOT NULL, + user_id TEXT NOT NULL, + account_ref TEXT NOT NULL, + encrypted_payload TEXT NOT NULL, + credential_version INTEGER NOT NULL DEFAULT 1, + last_rotated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (daemon_id, broker) +) +``` + +`ensure_schema()`는 `IF NOT EXISTS`를 사용하므로 테이블이 이미 존재하면 CREATE를 스킵한다. +Step2 마이그레이션이 적용된 DB에서는 이 정의와 실제 스키마가 정합된다. +Step1만 적용된 DB(Step2 미적용)에서 `ensure_schema()`를 호출하면 테이블은 이미 존재하므로 스킵되며 영향 없다. + +Step2 이후 추가될 인덱스: +```sql +CREATE INDEX IF NOT EXISTS idx_broker_credentials_daemon_broker +ON broker_credentials (daemon_id, broker) +``` +이 인덱스는 PK 자체이므로 별도 인덱스는 불필요. Step1에서 만든 `idx_broker_credentials_daemon_id` 인덱스는 그대로 유지. + +#### upsert_broker_credential (T-2) + +현재: +- Primary INSERT: `ON CONFLICT (user_id, broker)` — Step2 이후 PK가 바뀌어 동작 안 함 +- Fallback: `ON CONFLICT (user_id, broker)` without daemon_id — Step1 미적용 환경용 + +Step2 이후 세계에서: +- daemon_id는 항상 NOT NULL (Step2 PK 제약) +- ON CONFLICT는 `(daemon_id, broker)` 로 변경 +- Fallback 불필요 → 제거 + +단, BAT-020의 fallback try/except는 `psycopg` 트랜잭션 abort 이슈가 있었다: +트랜잭션이 abort 상태에서 fallback 쿼리를 실행하면 `InFailedSqlTransaction` 오류가 발생. +이 batch에서 fallback을 제거하면서 이 이슈도 동시에 해소된다. + +갱신 후 `upsert_broker_credential`: +```python +cur.execute( + """ + INSERT INTO broker_credentials (daemon_id, user_id, broker, account_ref, encrypted_payload) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (daemon_id, broker) DO UPDATE + SET account_ref = EXCLUDED.account_ref, + encrypted_payload = EXCLUDED.encrypted_payload, + user_id = EXCLUDED.user_id, + credential_version = broker_credentials.credential_version + 1, + last_rotated_at = NOW() + RETURNING daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at + """, + (daemon_id, user_id, broker, account_ref, encrypted_payload), +) +``` + +RETURNING 절에서 `daemon_id` 컬럼 순서 변경: `_row_to_broker_credential`의 현재 row 매핑 확인 필요. + +현재 `_row_to_broker_credential`: +```python +return BrokerCredential( + user_id=row[0], # user_id + broker=row[1], # broker + account_ref=row[2], + encrypted_payload=row[3], + credential_version=int(row[4]), + last_rotated_at=row[5], + daemon_id=row[6], # optional +) +``` + +RETURNING 컬럼 순서를 유지해야 한다. 새 RETURNING: +`daemon_id(0), user_id(1), broker(2), account_ref(3), encrypted_payload(4), credential_version(5), last_rotated_at(6)` +→ `_row_to_broker_credential` 매핑도 함께 수정 필요. + +`list_broker_credentials`도 같은 방향으로 정렬: +`SELECT daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at` + +`_row_to_broker_credential` 갱신: +```python +return BrokerCredential( + daemon_id=row[0], + user_id=row[1], + broker=row[2], + account_ref=row[3], + encrypted_payload=row[4], + credential_version=int(row[5]), + last_rotated_at=row[6], +) +``` + +#### list_broker_credentials fallback 제거 (T-3) + +Step2 이후 daemon_id 컬럼이 항상 존재하므로 try/except fallback 제거. + +### 영향 범위 + +- `app/repositories/postgres.py` — ensure_schema, upsert_broker_credential, list_broker_credentials, _row_to_broker_credential +- `tests/` — bat021 신규 테스트, 기존 FakeRepo 의존 테스트 영향 없음 (FakeRepo는 실제 DB 쿼리 미사용) + +### 하위 호환성 + +Step2 PK 전환 이후에만 이 코드가 올바르게 동작한다. +Step2 미적용 DB에서는 `ON CONFLICT (daemon_id, broker)` 제약이 없어 오류 발생 가능. +런북에서 Step2 적용 이전에 새 코드를 배포하지 말 것을 명시한다. + +배포 순서: Step1 → backfill → Step2 → 새 코드 배포. diff --git a/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/implementation.md b/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/implementation.md new file mode 100644 index 0000000..a618a31 --- /dev/null +++ b/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/implementation.md @@ -0,0 +1,60 @@ +# BAT-021 Implementation + +## 변경 파일 + +### `app/repositories/postgres.py` + +**T-1: ensure_schema() broker_credentials CREATE TABLE 갱신** + +구 정의 (Step1 이전): +```sql +PRIMARY KEY (user_id, broker) +-- daemon_id 없음 +``` + +신 정의 (Step2 이후): +```sql +daemon_id TEXT NOT NULL, +broker TEXT NOT NULL, +user_id TEXT NOT NULL, +account_ref TEXT NOT NULL, +encrypted_payload TEXT NOT NULL, +credential_version INTEGER NOT NULL DEFAULT 1, +last_rotated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), +PRIMARY KEY (daemon_id, broker) +``` + +**T-2: `_row_to_broker_credential` Step2 컬럼 순서 적용** + +구 매핑: `row[0]=user_id, row[1]=broker, ..., row[6]=daemon_id` + +신 매핑: `row[0]=daemon_id, row[1]=user_id, row[2]=broker, row[3]=account_ref, row[4]=encrypted_payload, row[5]=credential_version, row[6]=last_rotated_at` + +**T-3: `upsert_broker_credential` ON CONFLICT 수정 + fallback 제거** + +- `ON CONFLICT (user_id, broker)` → `ON CONFLICT (daemon_id, broker)` +- `RETURNING` 컬럼 순서: `daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at` +- `try/except` fallback 완전 제거 (트랜잭션 abort 이슈 동시 해소) + +**T-4: `list_broker_credentials` fallback 제거 + SELECT 컬럼 순서 수정** + +- `try/except` fallback 완전 제거 +- `SELECT daemon_id, user_id, broker, ...` 순서로 변경 + +### `tests/test_bat020_broker_credential_domain.py` + +- `test_row_to_broker_credential_with_daemon_id`: 행 순서를 Step2 컬럼 순서로 수정 (AC-3 갱신) +- `test_row_to_broker_credential_without_daemon_id`: Step2 5컬럼 기본값 테스트로 갱신 (AC-4 갱신) +- `_make_rows_for_user`: Step2 컬럼 순서로 갱신 + +### 신규 파일 + +- `tests/test_bat021_step2_ensure_schema_and_upsert.py`: 12개 신규 테스트 +- `docs/runbooks/bat-021-req047-step2-migration-runbook.md` +- `docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/` (index, planning, design, implementation, verification) + +## 테스트 count (구현 후) + +- 기존: 509 passed +- 신규: 12 추가 (test_bat021_step2_ensure_schema_and_upsert.py) +- 합계: 521 passed diff --git a/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/index.md b/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/index.md new file mode 100644 index 0000000..4d64e40 --- /dev/null +++ b/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/index.md @@ -0,0 +1,28 @@ +# BAT-021 Index + +- Profile: standard +- Status: in-progress +- Included REQ: REQ-047 +- Discovery: dcy-010 +- Created: 2026-06-28 + +## Scope Summary + +REQ-047 Step2 마무리 batch. + +BAT-020(PR #17, d4acfd4)에서 Step1/Step2 마이그레이션 revision 코드가 작성됐으나 +실 PostgreSQL 통합검증이 미완이고, Step2 적용 후 동반 코드 수정 2건이 TODO로 잔존했다. + +이 batch는: +1. `ensure_schema()` `broker_credentials` CREATE TABLE을 최종 daemon-scoped 스키마로 갱신 +2. `upsert_broker_credential` `ON CONFLICT (user_id, broker)` → `ON CONFLICT (daemon_id, broker)` 수정 + fallback 트랜잭션 abort 이슈 정리 +3. dev/test PostgreSQL에서 Step1 → backfill → Step2 → downgrade 통합검증 (AC-1~7) +4. 운영 적용 런북 작성 +5. 신규/갱신 단위 테스트 추가 + +## Documents + +- [planning.md](./planning.md) +- [design.md](./design.md) +- [implementation.md](./implementation.md) +- [verification.md](./verification.md) diff --git a/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/planning.md b/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/planning.md new file mode 100644 index 0000000..bab5fcf --- /dev/null +++ b/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/planning.md @@ -0,0 +1,34 @@ +# BAT-021 Planning + +## DoR 확인 + +- [x] REQ-047: `Approved` 상태 확인 +- [x] BAT-020 코드 머지 (d4acfd4) — Step1/Step2 revision 존재 +- [x] dev PostgreSQL (Docker) 가동 확인 +- [x] 동반 수정 TODO 2건 식별 완료 (verification.md 미해결 사항 1, 2) + +## 작업 분해 + +| # | 작업 | 파일 | 비고 | +| --- | --- | --- | --- | +| T-1 | `ensure_schema()` broker_credentials 갱신 | `app/repositories/postgres.py` | daemon_id NOT NULL, PK=(daemon_id,broker) | +| T-2 | `upsert_broker_credential` ON CONFLICT 수정 | `app/repositories/postgres.py` | (daemon_id,broker) + fallback 제거 | +| T-3 | list_broker_credentials fallback 제거 | `app/repositories/postgres.py` | Step2 이후 컬럼 항상 존재 | +| T-4 | Step1 alembic upgrade 실 DB 검증 | dev PostgreSQL | AC-1, AC-2 | +| T-5 | backfill CLI 실행 (테스트 데이터) | pfhub CLI | AC(REQ-048) | +| T-6 | Step2 alembic upgrade 실 DB 검증 | dev PostgreSQL | AC-3, AC-4, AC-5 | +| T-7 | downgrade 실 DB 검증 (Step2 → Step1 → base) | dev PostgreSQL | AC-6, AC-7 | +| T-8 | 단위 테스트 추가/갱신 | `tests/test_bat021_*.py` | ensure_schema 정합, ON CONFLICT 행동 | +| T-9 | 운영 런북 작성 | `docs/runbooks/bat-021-runbook.md` | 백업→Step1→backfill→Step2→verify→rollback | +| T-10 | batch 문서 완성 | docs/batches/bat-021 | implementation.md, verification.md | + +## Design Gate + +Design 필요: 코드 수정 범위가 명확(T-1/T-2/T-3 — 두 함수 내 SQL 수정)하나 +fallback 패턴 제거 및 ensure_schema 스키마 정합성 확인이 필요하므로 design.md를 작성한다. + +## 위험 + +- R-1: Step2 upgrade 중 NULL 행 있으면 revision 코드가 RuntimeError 발생 → 사전에 backfill 완료 확인 필수 +- R-2: `ensure_schema()` 변경 후 Step2 미적용 DB에서 실행 시 NOT NULL/PK 불일치 → ensure_schema는 Step2 적용 후 동작 환경에서만 호출 (런북에 명시) +- R-3: fallback 제거 시 Step1 미적용 DB에서 upsert 호출 시 오류 → 이 batch는 Step2 이후 세계를 목표로 하므로 Step1 이전 환경 지원 불필요 (런북에 명시) diff --git a/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/verification.md b/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/verification.md new file mode 100644 index 0000000..549fff9 --- /dev/null +++ b/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/verification.md @@ -0,0 +1,164 @@ +# BAT-021 Verification + +## pytest 실행 결과 + +``` +python3 -m pytest tests/ --tb=short +521 passed, 11 warnings in 26.67s +``` + +기존 테스트: 509 passed (BAT-020 기준) +신규 테스트: 12 added (test_bat021_step2_ensure_schema_and_upsert.py) +기존 테스트 회귀: 0건 + +--- + +## REQ-047 AC 검증 (실 PostgreSQL) + +### 검증 환경 + +- Docker: `portfolio-hub-timescaledb` (timescale/timescaledb:latest-pg16) +- DB: `portfolio_hub` (127.0.0.1:5432) +- 시작 alembic 상태: 20260601_0024 (ensure_schema()로 스키마 구성, alembic 미동기 상태) +- stamp 조치: `alembic stamp 20260605_0025` (DB 실제 스키마 = 0025 적용 상태 반영) + +### AC-1: Step1 후 daemon_id TEXT NULL 컬럼 존재, PK (user_id,broker) 유지 + +``` +alembic upgrade 20260628_0026 +``` + +결과: +``` +daemon_id | text | nullable=YES +PK: broker_credentials_pkey PRIMARY KEY btree (user_id, broker) +``` + +**PASS** + +### AC-2: Step1 후 기존 데이터 daemon_id=NULL 보존 + +```sql +INSERT INTO broker_credentials ... VALUES ('test-user-1', 'kis', ...) +SELECT daemon_id FROM broker_credentials WHERE user_id='test-user-1'; +-- 결과: NULL +``` + +**PASS** + +### AC-3: Step2 후 PK (daemon_id, broker) 전환, daemon_id NOT NULL + +``` +# backfill: UPDATE broker_credentials SET daemon_id='test-daemon-1' WHERE daemon_id IS NULL +alembic upgrade 20260628_0027 +``` + +결과: +``` +daemon_id | text | nullable=NO +PK: broker_credentials_pkey PRIMARY KEY btree (daemon_id, broker) +``` + +**PASS** + +### AC-4: Step2 후 user_id 컬럼 유지 + +```sql +SELECT column_name FROM information_schema.columns +WHERE table_name='broker_credentials' AND column_name='user_id'; +-- 결과: user_id 존재 +``` + +**PASS** + +### AC-5: Step2 후 ensure_schema() 정합 + +```python +repo.ensure_schema() # 오류 없음 +# DB 조회 결과: +# daemon_id: NOT NULL, PK=(daemon_id, broker), user_id 존재 +``` + +**PASS** + +### AC-6: Step1 downgrade — daemon_id 컬럼 제거, PK (user_id,broker) 복원 + +``` +alembic downgrade 20260605_0025 +``` + +결과: +``` +broker_credentials: daemon_id 컬럼 없음 +PK: broker_credentials_pkey PRIMARY KEY btree (user_id, broker) +``` + +**PASS** + +### AC-7: Step2 downgrade 안전장치 — 중복 (user_id,broker) 존재 시 RuntimeError + +``` +# 2개 daemon이 같은 user/broker 사용하도록 데이터 구성 후 downgrade 시도 +alembic downgrade 20260628_0026 +``` + +결과: +``` +RuntimeError: downgrade 불가: 1개 (user_id, broker) 조합이 중복됩니다. +같은 user의 여러 daemon이 동일 broker를 사용하고 있어 +(user_id, broker) PK로 복원 시 데이터 손실이 발생합니다. +수동으로 데이터를 정리한 후 downgrade하세요. +``` + +**PASS** + +--- + +## 동반 코드 검증 + +### upsert_broker_credential (ON CONFLICT + row mapper) + +```python +cred = repo.upsert_broker_credential( + 'bat021-test-user', 'kis', + account_ref='BAT021-ACCOUNT', + encrypted_payload='bat021-encrypted-payload', + daemon_id='bat021-daemon-1' +) +# 결과: daemon_id='bat021-daemon-1', user_id='bat021-test-user', broker='kis', account_ref='BAT021-ACCOUNT' +cred2 = repo.upsert_broker_credential(..., account_ref='BAT021-ACCOUNT-UPDATED') +# 결과: version=2, account_ref='BAT021-ACCOUNT-UPDATED' (idempotent update OK) +``` + +**PASS** + +### list_broker_credentials (Step2 컬럼 순서) + +```python +creds = repo.list_broker_credentials('bat021-test-user', daemon_id='bat021-daemon-1') +# 결과: 1건, daemon_id='bat021-daemon-1', broker='kis' +``` + +**PASS** + +--- + +## Step2 NULL 사전 검증 (REQ-048 미수행 시 거부) + +``` +# daemon_id IS NULL 행 1건 존재 상태에서 Step2 upgrade 시도 +RuntimeError: broker_credentials에 daemon_id IS NULL인 행이 1건 있습니다. +REQ-048 backfill-daemon-id CLI로 모든 행을 백필한 후 다시 실행하세요. +``` + +**PASS** + +--- + +## 미해결/위험 사항 + +없음 — 이 batch의 TODO 2건 모두 해소됨: +1. ensure_schema() broker_credentials 갱신: **완료** +2. upsert ON CONFLICT (user_id,broker) → (daemon_id,broker) + fallback 제거: **완료** + +실 운영 DB 적용은 런북에 따라 사장님 결재 후 운영자가 수행한다. diff --git a/docs/batches/index.md b/docs/batches/index.md index e0fbc12..01cdb03 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -27,4 +27,5 @@ | BAT-017 | standard | released | REQ-039, REQ-040, REQ-041, REQ-042 | dcy-007 | [bat-017](./bat-017_20260618_trade-history-and-pnl-api/) | | BAT-018 | batch-lite | release-candidate | REQ-043 | dcy-008 | [bat-018](./bat-018_20260625_holdings-cost-basis-endpoint/) | | BAT-019 | standard | confirmed | REQ-044, REQ-045 | dcy-009 | [bat-019](./bat-019_20260627_assets-read-permission-and-daemon-list/) | -| BAT-020 | standard | draft-batch-verification | REQ-046, REQ-047, REQ-048, REQ-049, REQ-050, REQ-051 | dcy-010 | [bat-020](./bat-020_20260628_daemon-scoped-broker-credential/) | +| BAT-020 | standard | release-candidate | REQ-046, REQ-047, REQ-048, REQ-049, REQ-050, REQ-051 | dcy-010 | [bat-020](./bat-020_20260628_daemon-scoped-broker-credential/) | +| BAT-021 | standard | in-progress | REQ-047 | dcy-010 | [bat-021](./bat-021_20260628_req047-step2-integration-and-runbook/) | diff --git a/docs/runbooks/bat-021-req047-step2-migration-runbook.md b/docs/runbooks/bat-021-req047-step2-migration-runbook.md new file mode 100644 index 0000000..f608154 --- /dev/null +++ b/docs/runbooks/bat-021-req047-step2-migration-runbook.md @@ -0,0 +1,193 @@ +# 운영 적용 런북: REQ-047 Step2 — broker_credentials PK 전환 (daemon_id, broker) + +- Batch: BAT-021 +- Discovery: dcy-010 +- Date: 2026-06-28 +- 결재 게이트: 사장님 결재 필수 (비가역 PK 변경) + +## 전제 조건 + +- REQ-046 ~ REQ-051 코드(BAT-020 PR #17, d4acfd4)가 배포돼 있어야 한다. +- BAT-021 코드(ensure_schema 갱신, ON CONFLICT 수정)는 **Step2 적용 완료 이후** 배포한다(아래 배포 순서 준수). +- 마이그레이션 전 DB 백업이 완료돼 있어야 한다. + +## 배포 순서 개요 + +``` +백업 → Step1 upgrade → backfill CLI → NULL 0건 확인 → Step2 upgrade(결재) → 코드 배포 → 동작 확인 → 비상 시 롤백 +``` + +--- + +## 1단계: DB 백업 + +운영 DB에 마이그레이션을 적용하기 전 전체 스냅샷을 백업한다. + +```bash +# 예: pg_dump 사용 +pg_dump -h -U -d -F c -f backup_pre_bat021_$(date +%Y%m%d%H%M%S).dump +``` + +--- + +## 2단계: Step1 alembic upgrade (daemon_id NULL 컬럼 추가) + +```bash +export TIMESCALEDB_HOST= +export TIMESCALEDB_PORT= +export TIMESCALEDB_NAME= +export TIMESCALEDB_USER= +export TIMESCALEDB_PASSWORD= +export PORTFOLIOHUB_DB_ENABLED=true + +alembic upgrade 20260628_0026 +``` + +예상 출력: +``` +INFO [alembic.runtime.migration] Running upgrade -> 20260628_0026, broker_credentials: daemon_id TEXT NULL 컬럼 추가 (REQ-047 Step1) +``` + +### 검증 + +```sql +-- broker_credentials에 daemon_id TEXT NULL 컬럼이 존재해야 함 +\d broker_credentials +-- 또는 +SELECT column_name, is_nullable FROM information_schema.columns +WHERE table_name = 'broker_credentials' AND column_name = 'daemon_id'; +-- 기대: daemon_id | YES (nullable) + +-- 기존 PK (user_id, broker) 유지 확인 +SELECT constraint_name, constraint_type FROM information_schema.table_constraints +WHERE table_name = 'broker_credentials' AND constraint_type = 'PRIMARY KEY'; +-- 기대: broker_credentials_pkey | PRIMARY KEY + +-- 기존 데이터 daemon_id=NULL 보존 확인 +SELECT COUNT(*) FROM broker_credentials; -- 기존 행 수 유지 +``` + +--- + +## 3단계: backfill CLI — daemon_id 매핑 + +모든 기존 broker_credentials 행에 daemon_id를 설정한다. + +```bash +# 사용자별로 실행 (daemon_id는 해당 user의 daemon id) +pfhub credential backfill-daemon-id --user --daemon-id + +# --dry-run으로 먼저 확인 +pfhub credential backfill-daemon-id --user --daemon-id --dry-run +``` + +### 검증: NULL 0건 확인 + +```sql +-- 이 쿼리가 0을 반환해야 Step2를 진행할 수 있다 +SELECT COUNT(*) FROM broker_credentials WHERE daemon_id IS NULL; +-- 기대: 0 +``` + +**경고: NULL이 1건이라도 있으면 Step2 upgrade가 RuntimeError로 거부된다. 반드시 0건 확인 후 진행.** + +--- + +## 4단계: Step2 alembic upgrade (PK 전환) — 사장님 결재 필요 + +이 단계는 비가역 PK 변경이다. **사장님 결재 후에만 실행한다.** + +```bash +alembic upgrade 20260628_0027 +``` + +예상 출력: +``` +INFO [alembic.runtime.migration] Running upgrade 20260628_0026 -> 20260628_0027, broker_credentials: PK (user_id,broker) → (daemon_id,broker) 전환 (REQ-047 Step2) +``` + +### 검증: Step2 완료 확인 + +```sql +-- PK가 (daemon_id, broker)로 변경됐는지 확인 +SELECT constraint_name FROM information_schema.table_constraints +WHERE table_name = 'broker_credentials' AND constraint_type = 'PRIMARY KEY'; +-- 기대: broker_credentials_pkey + +SELECT pg_get_constraintdef(oid) +FROM pg_constraint +WHERE conname = 'broker_credentials_pkey'; +-- 기대: PRIMARY KEY (daemon_id, broker) + +-- daemon_id NOT NULL 확인 +SELECT column_name, is_nullable FROM information_schema.columns +WHERE table_name = 'broker_credentials' AND column_name = 'daemon_id'; +-- 기대: daemon_id | NO (not nullable) + +-- user_id 컬럼 유지 확인 +SELECT column_name FROM information_schema.columns +WHERE table_name = 'broker_credentials' AND column_name = 'user_id'; +-- 기대: user_id 존재 +``` + +--- + +## 5단계: 코드 배포 (BAT-021 코드 포함) + +Step2 alembic upgrade가 완료된 후에 코드를 배포한다. + +포함 변경: +- `app/repositories/postgres.py`: + - `ensure_schema()` broker_credentials: daemon_id NOT NULL, PK=(daemon_id, broker) + - `_row_to_broker_credential()`: Step2 컬럼 순서 (daemon_id 선두) + - `upsert_broker_credential()`: ON CONFLICT (daemon_id, broker), fallback 제거 + - `list_broker_credentials()`: fallback 제거, Step2 컬럼 순서 + +--- + +## 6단계: 동작 확인 + +```bash +# pfhub CLI로 인증정보 조회/등록 테스트 +pfhub credential list --user --daemon-id + +# 서비스 헬스체크 +curl http:///health +``` + +--- + +## 롤백 절차 + +### Step2 롤백 (alembic downgrade) + +Step2 downgrade는 (user_id, broker) 중복이 없는 경우에만 가능하다. + +```bash +# Step2 → Step1 상태로 복원 +alembic downgrade 20260628_0026 +``` + +**경고**: 같은 user의 여러 daemon이 동일 broker를 사용하는 경우 downgrade가 RuntimeError로 거부된다. +이 경우 수동 데이터 정리 후 downgrade하거나 백업에서 복원한다. + +### Step1 롤백 + +```bash +# Step1 → 이전 상태로 복원 (daemon_id 컬럼 제거) +alembic downgrade 20260605_0025 +``` + +### DB 복원 (최후 수단) + +```bash +pg_restore -h -U -d -c backup_pre_bat021_.dump +``` + +--- + +## 주의사항 + +- Step2 이전에 BAT-021 코드를 배포하면 `ON CONFLICT (daemon_id, broker)` 절이 구 스키마와 맞지 않아 오류 발생. +- 배포 순서: Step1 → backfill → Step2 → 코드 배포 순서를 엄수한다. +- `ensure_schema()`는 `IF NOT EXISTS`를 사용하므로 Step2 적용 후 실행 시 테이블이 이미 존재해 스킵됨 (신규 인스턴스 초기화 시에만 적용됨). diff --git a/tests/test_bat020_broker_credential_domain.py b/tests/test_bat020_broker_credential_domain.py index 4c9219b..33867da 100644 --- a/tests/test_bat020_broker_credential_domain.py +++ b/tests/test_bat020_broker_credential_domain.py @@ -42,37 +42,42 @@ def test_broker_credential_daemon_id_set() -> None: # --------------------------------------------------------------------------- -# REQ-046 AC-3: _row_to_broker_credential 7컬럼(daemon_id 포함) 처리 +# REQ-046 AC-3 / BAT-021: _row_to_broker_credential Step2 컬럼 순서 검증 +# Step2(REQ-047) 이후 컬럼 순서: (daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at) # --------------------------------------------------------------------------- def test_row_to_broker_credential_with_daemon_id() -> None: - """REQ-046 AC-3: 7컬럼 행(daemon_id 포함) 변환 시 daemon_id 세팅.""" + """REQ-046 AC-3 / BAT-021: Step2 컬럼 순서(daemon_id 선두) 행 변환 검증.""" from app.repositories.postgres import PostgresPortfolioRepository now = datetime.now(timezone.utc) - row = ("user-1", "kis", "12345678", "encrypted", 2, now, "daemon-xyz") + # Step2 이후 RETURNING/SELECT 순서: daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at + row = ("daemon-xyz", "user-1", "kis", "12345678", "encrypted", 2, now) cred = PostgresPortfolioRepository._row_to_broker_credential(row) + assert cred.daemon_id == "daemon-xyz" assert cred.user_id == "user-1" assert cred.broker == "kis" assert cred.account_ref == "12345678" assert cred.encrypted_payload == "encrypted" assert cred.credential_version == 2 assert cred.last_rotated_at == now - assert cred.daemon_id == "daemon-xyz" # --------------------------------------------------------------------------- -# REQ-046 AC-4: _row_to_broker_credential 6컬럼(daemon_id 없음) 하위호환 +# REQ-046 AC-4 / BAT-021: _row_to_broker_credential credential_version 기본값 +# Step2 이후 daemon_id는 항상 존재. 6컬럼(credential_version 없음) 기본값 1 확인. # --------------------------------------------------------------------------- def test_row_to_broker_credential_without_daemon_id() -> None: - """REQ-046 AC-4: 6컬럼 행(daemon_id 없음) 변환 시 daemon_id=None (하위호환).""" + """REQ-046 AC-4 / BAT-021: credential_version 없는 5컬럼 행 → credential_version 기본값 1.""" from app.repositories.postgres import PostgresPortfolioRepository - now = datetime.now(timezone.utc) - row = ("user-1", "kis", "12345678", "encrypted", 1, now) + # Step2 이후 컬럼 순서: daemon_id, user_id, broker, account_ref, encrypted_payload (5컬럼) + row = ("daemon-abc", "user-1", "kis", "12345678", "encrypted") cred = PostgresPortfolioRepository._row_to_broker_credential(row) - assert cred.daemon_id is None + assert cred.daemon_id == "daemon-abc" + assert cred.credential_version == 1 + assert cred.last_rotated_at is None # --------------------------------------------------------------------------- @@ -109,11 +114,11 @@ class _FakeConn: def _make_rows_for_user(user_id: str): - """daemon_id "d1", "d2" 각각 1건씩.""" + """Step2 컬럼 순서: (daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at).""" now = datetime.now(timezone.utc) return [ - (user_id, "kis", "ref-d1", "enc-d1", 1, now, "d1"), - (user_id, "kis2", "ref-d2", "enc-d2", 1, now, "d2"), + ("d1", user_id, "kis", "ref-d1", "enc-d1", 1, now), + ("d2", user_id, "kis2", "ref-d2", "enc-d2", 1, now), ] diff --git a/tests/test_bat021_step2_ensure_schema_and_upsert.py b/tests/test_bat021_step2_ensure_schema_and_upsert.py new file mode 100644 index 0000000..6e2d704 --- /dev/null +++ b/tests/test_bat021_step2_ensure_schema_and_upsert.py @@ -0,0 +1,252 @@ +"""BAT-021: REQ-047 Step2 동반 코드 수정 단위 테스트. + +검증 범위: +- ensure_schema() broker_credentials CREATE TABLE: daemon-scoped PK 정의 +- _row_to_broker_credential: Step2 컬럼 순서 (daemon_id 선두) +- upsert_broker_credential: ON CONFLICT (daemon_id, broker), fallback 제거 +- list_broker_credentials: fallback 제거, Step2 컬럼 순서 +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pytest + +from app.domain.models import BrokerCredential +from app.repositories.postgres import PortfolioDBConfig, PostgresPortfolioRepository + + +# --------------------------------------------------------------------------- +# 공통 픽스처 +# --------------------------------------------------------------------------- + +def _make_config() -> PortfolioDBConfig: + return PortfolioDBConfig( + host="localhost", + port=5432, + dbname="test", + user="test", + password="test", + sslmode="disable", + ) + + +def _make_repo() -> PostgresPortfolioRepository: + return PostgresPortfolioRepository(_make_config()) + + +# --------------------------------------------------------------------------- +# ensure_schema broker_credentials 스키마 정의 검증 +# --------------------------------------------------------------------------- + +def test_ensure_schema_broker_credentials_ddl_contains_daemon_id_pk() -> None: + """BAT-021: ensure_schema()의 broker_credentials CREATE TABLE에 daemon_id NOT NULL과 + PRIMARY KEY (daemon_id, broker)가 포함되어 있다.""" + import inspect + import app.repositories.postgres as repo_module + + source = inspect.getsource(repo_module) + # CREATE TABLE에 daemon_id NOT NULL 포함 확인 + assert "daemon_id TEXT NOT NULL" in source, "ensure_schema broker_credentials에 daemon_id NOT NULL 없음" + # PRIMARY KEY (daemon_id, broker) 포함 확인 + assert "PRIMARY KEY (daemon_id, broker)" in source, "ensure_schema broker_credentials PK가 (daemon_id,broker) 아님" + + +def test_ensure_schema_broker_credentials_no_old_pk_user_id_broker() -> None: + """BAT-021: ensure_schema()의 broker_credentials CREATE TABLE에 구 PK (user_id, broker)가 없다.""" + import inspect + import app.repositories.postgres as repo_module + + source = inspect.getsource(repo_module) + # ensure_schema 함수 영역만 검사: broker_credentials CREATE TABLE + # "PRIMARY KEY (user_id, broker)" 패턴이 없어야 함 + assert "PRIMARY KEY (user_id, broker)" not in source, "ensure_schema에 구 PK (user_id,broker) 잔존" + + +# --------------------------------------------------------------------------- +# _row_to_broker_credential Step2 컬럼 순서 +# --------------------------------------------------------------------------- + +def test_row_to_broker_credential_step2_7col_canonical_order() -> None: + """BAT-021: Step2 표준 7컬럼 행 변환 — daemon_id 선두.""" + now = datetime.now(timezone.utc) + # Step2: (daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at) + row = ("daemon-abc", "user-1", "kis", "ACC-001", "enc-payload", 3, now) + cred = PostgresPortfolioRepository._row_to_broker_credential(row) + + assert cred.daemon_id == "daemon-abc" + assert cred.user_id == "user-1" + assert cred.broker == "kis" + assert cred.account_ref == "ACC-001" + assert cred.encrypted_payload == "enc-payload" + assert cred.credential_version == 3 + assert cred.last_rotated_at == now + + +def test_row_to_broker_credential_step2_5col_no_version() -> None: + """BAT-021: 5컬럼 행(credential_version 없음) — 기본값 1, last_rotated_at=None.""" + row = ("daemon-xyz", "user-2", "kis2", "ACC-002", "enc-2") + cred = PostgresPortfolioRepository._row_to_broker_credential(row) + + assert cred.daemon_id == "daemon-xyz" + assert cred.user_id == "user-2" + assert cred.broker == "kis2" + assert cred.credential_version == 1 + assert cred.last_rotated_at is None + + +# --------------------------------------------------------------------------- +# upsert_broker_credential: ON CONFLICT (daemon_id, broker) +# --------------------------------------------------------------------------- + +def test_upsert_broker_credential_uses_daemon_id_conflict_target() -> None: + """BAT-021: upsert_broker_credential SQL에 ON CONFLICT (daemon_id, broker) 포함 확인.""" + import inspect + import app.repositories.postgres as repo_module + + # upsert_broker_credential 메서드 소스 추출 + source = inspect.getsource( + PostgresPortfolioRepository.upsert_broker_credential + ) + assert "ON CONFLICT (daemon_id, broker)" in source, \ + "upsert_broker_credential에 ON CONFLICT (daemon_id, broker) 없음" + + +def test_upsert_broker_credential_no_old_conflict_target() -> None: + """BAT-021: upsert_broker_credential SQL에 구 ON CONFLICT (user_id, broker) 없음.""" + import inspect + + source = inspect.getsource( + PostgresPortfolioRepository.upsert_broker_credential + ) + assert "ON CONFLICT (user_id, broker)" not in source, \ + "upsert_broker_credential에 구 ON CONFLICT (user_id,broker) 잔존" + + +def test_upsert_broker_credential_no_fallback_try_except() -> None: + """BAT-021: upsert_broker_credential에 fallback try/except 없음 (트랜잭션 abort 이슈 해소).""" + import inspect + + source = inspect.getsource( + PostgresPortfolioRepository.upsert_broker_credential + ) + # Fallback try/except는 "Fallback" 주석과 함께 있었음 — 완전 제거 확인 + assert "Fallback: daemon_id" not in source, \ + "upsert_broker_credential에 Fallback try/except 잔존" + + +def test_upsert_broker_credential_mock_returns_step2_row() -> None: + """BAT-021: upsert_broker_credential mock 기반 — Step2 RETURNING 순서(daemon_id 선두)로 BrokerCredential 생성.""" + now = datetime.now(timezone.utc) + # Step2 RETURNING 순서: daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at + mock_row = ("daemon-test", "user-test", "kis", "REF-001", "enc-test", 1, now) + + repo = _make_repo() + + mock_cursor = MagicMock() + mock_cursor.__enter__ = lambda s: mock_cursor + mock_cursor.__exit__ = MagicMock(return_value=False) + # user existence check + mock_cursor.fetchone.side_effect = [MagicMock(), mock_row] # user exists, then credential row + + mock_conn = MagicMock() + mock_conn.cursor.return_value = mock_cursor + repo._connection = mock_conn + + with patch.object(repo, "ensure_schema"): + cred = repo.upsert_broker_credential( + "user-test", "kis", + account_ref="REF-001", + encrypted_payload="enc-test", + daemon_id="daemon-test", + ) + + assert cred.daemon_id == "daemon-test" + assert cred.user_id == "user-test" + assert cred.broker == "kis" + assert cred.account_ref == "REF-001" + + +# --------------------------------------------------------------------------- +# list_broker_credentials: fallback 제거, Step2 컬럼 순서 +# --------------------------------------------------------------------------- + +def test_list_broker_credentials_no_fallback_try_except() -> None: + """BAT-021: list_broker_credentials에 fallback try/except 없음.""" + import inspect + + source = inspect.getsource( + PostgresPortfolioRepository.list_broker_credentials + ) + assert "Fallback: daemon_id" not in source, \ + "list_broker_credentials에 Fallback try/except 잔존" + + +def test_list_broker_credentials_step2_select_order() -> None: + """BAT-021: list_broker_credentials SELECT 쿼리에 daemon_id 선두 컬럼 순서.""" + import inspect + + source = inspect.getsource( + PostgresPortfolioRepository.list_broker_credentials + ) + # daemon_id가 SELECT에서 user_id보다 앞에 있어야 함 + daemon_pos = source.find("SELECT daemon_id") + assert daemon_pos != -1, "list_broker_credentials SELECT에 daemon_id 선두 없음" + + +def test_list_broker_credentials_mock_returns_step2_rows() -> None: + """BAT-021: list_broker_credentials mock — Step2 컬럼 순서로 BrokerCredential 생성.""" + now = datetime.now(timezone.utc) + # Step2 컬럼 순서: (daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at) + mock_rows = [ + ("daemon-1", "user-1", "kis", "REF-A", "enc-a", 1, now), + ("daemon-2", "user-1", "kis2", "REF-B", "enc-b", 1, now), + ] + + repo = _make_repo() + + mock_cursor = MagicMock() + mock_cursor.__enter__ = lambda s: mock_cursor + mock_cursor.__exit__ = MagicMock(return_value=False) + mock_cursor.fetchall.return_value = mock_rows + + mock_conn = MagicMock() + mock_conn.cursor.return_value = mock_cursor + repo._connection = mock_conn + + with patch.object(repo, "ensure_schema"): + results = repo.list_broker_credentials("user-1") + + assert len(results) == 2 + assert results[0].daemon_id == "daemon-1" + assert results[0].broker == "kis" + assert results[1].daemon_id == "daemon-2" + assert results[1].broker == "kis2" + + +def test_list_broker_credentials_daemon_id_filter_step2() -> None: + """BAT-021: list_broker_credentials daemon_id 필터 — Step2 컬럼 순서.""" + now = datetime.now(timezone.utc) + mock_rows = [ + ("daemon-1", "user-1", "kis", "REF-A", "enc-a", 1, now), + ] + + repo = _make_repo() + + mock_cursor = MagicMock() + mock_cursor.__enter__ = lambda s: mock_cursor + mock_cursor.__exit__ = MagicMock(return_value=False) + mock_cursor.fetchall.return_value = mock_rows + + mock_conn = MagicMock() + mock_conn.cursor.return_value = mock_cursor + repo._connection = mock_conn + + with patch.object(repo, "ensure_schema"): + results = repo.list_broker_credentials("user-1", daemon_id="daemon-1") + + assert len(results) == 1 + assert results[0].daemon_id == "daemon-1" + assert results[0].broker == "kis"