feat(req-013): add portfolio-list-user-required requirement and batch

- Add REQ-013: Portfolio list filtered by user requirement with acceptance criteria
- Add DCY-003 discovery document for portfolio list user filtering
- Create BAT-003 batch with planning, design, implementation, and verification
- Enhance app/cli/commands/portfolio.py with user-based list filtering
- Add comprehensive tests for portfolio list CLI command with user filtering
- Update docs indices (discovery, srs, batches)
- Improve README with latest capability status
This commit is contained in:
2026-05-02 13:22:41 +00:00
parent b4d964253b
commit 2f686f79ce
12 changed files with 699 additions and 13 deletions
+1 -1
View File
@@ -108,7 +108,7 @@ pfhub user delete <user-id> --force
pfhub service-token issue nightly --user alice --ttl-days 30 pfhub service-token issue nightly --user alice --ttl-days 30
pfhub service-token issue permanent --user alice --no-expiry pfhub service-token issue permanent --user alice --no-expiry
pfhub service-token revoke nightly --force pfhub service-token revoke nightly --force
pfhub portfolio list pfhub portfolio list --user <username>
pfhub portfolio status 005930:tv --recent 10 pfhub portfolio status 005930:tv --recent 10
pfhub portfolio create 005930:tv --symbol 005930 --cash-balance 10000000 --force pfhub portfolio create 005930:tv --symbol 005930 --cash-balance 10000000 --force
pfhub portfolio buy 005930:tv --quantity 10 --price 71000 --fee 5000 --force pfhub portfolio buy 005930:tv --quantity 10 --price 71000 --fee 5000 --force
+26 -5
View File
@@ -51,6 +51,18 @@ def _with_service(ctx: typer.Context, callback):
repository.close() repository.close()
def _with_service_and_repo(ctx: typer.Context, callback):
app_context = ctx.obj
repository = app_context.repository_factory(app_context.settings)
service = PortfolioService(repository)
try:
result = execute_command(app_context.console, callback=lambda: callback(service, repository, app_context))
return result
finally:
if callable(getattr(type(repository), "close", None)):
repository.close()
def _confirm(force: bool, message: str) -> None: def _confirm(force: bool, message: str) -> None:
if force: if force:
return return
@@ -62,22 +74,31 @@ def _confirm(force: bool, message: str) -> None:
@portfolio_app.command("list") @portfolio_app.command("list")
def list_portfolios( def list_portfolios(
ctx: typer.Context, ctx: typer.Context,
user: str | None = typer.Option(None, "--user", help="Filter by username (required)."),
broker: str | None = typer.Option(None, help="Filter by broker."), broker: str | None = typer.Option(None, help="Filter by broker."),
status: str | None = typer.Option(None, help="Filter by status."), status: str | None = typer.Option(None, help="Filter by status."),
include_tags: list[str] | None = typer.Option(None, "--include-tag", help="Include tag filter. Repeatable."), include_tags: list[str] | None = typer.Option(None, "--include-tag", help="Include tag filter. Repeatable."),
exclude_tags: list[str] | None = typer.Option(None, "--exclude-tag", help="Exclude tag filter. Repeatable."), exclude_tags: list[str] | None = typer.Option(None, "--exclude-tag", help="Exclude tag filter. Repeatable."),
match_mode: str | None = typer.Option(None, "--match-mode", help="AND or OR for include tags."), match_mode: str | None = typer.Option(None, "--match-mode", help="AND or OR for include tags."),
) -> None: ) -> None:
detail = _with_service( if user is None:
ctx, ctx.obj.console.print("Error: --user 옵션은 필수입니다.")
lambda service, app_context: service.list_portfolios( raise typer.Exit(code=1)
def _callback(service, repository, app_context):
owner = repository.get_user_by_username(user)
if owner is None:
raise ValueError(f"사용자를 찾을 수 없습니다: {user}")
return service.list_portfolios(
owner_user_id=owner.id,
broker=broker, broker=broker,
status=status, status=status,
include_tags=include_tags, include_tags=include_tags,
exclude_tags=exclude_tags, exclude_tags=exclude_tags,
match_mode=match_mode, match_mode=match_mode,
), )
)
detail = _with_service_and_repo(ctx, _callback)
render_output(ctx.obj.console, ctx.obj.output_format, detail, empty_message="No portfolios found.") render_output(ctx.obj.console, ctx.obj.output_format, detail, empty_message="No portfolios found.")
@@ -0,0 +1,58 @@
# BAT-003 Implementation
- Status: completed
- Batch: BAT-003
- Profile: batch-lite
## Plan Summary
- CLI `pfhub portfolio list`에 `--user <username>` 필수 옵션 추가
- `_with_service_and_repo` 헬퍼로 repository 직접 접근 패턴 구현
- `repository.get_user_by_username(user)` 호출 → None이면 ValueError → exit code 1
- `service.list_portfolios(owner_user_id=owner.id, ...)` 소유자 필터 항상 전달
- README 예제 라인 갱신, breaking change 반영
- 기존 테스트 2개 수정 + 신규 4개 추가
## Changed Files
| File | Change |
|------|--------|
| `app/cli/commands/portfolio.py` | `_with_service_and_repo` 헬퍼 추가, `list_portfolios` `--user` 필수화 및 소유자 필터 적용 |
| `tests/test_cli.py` | `test_cli_list_renders_table`, `test_cli_list_supports_tag_filters` 수정; AC 1~4 커버 4개 케이스 추가 |
| `README.md` | `pfhub portfolio list` → `pfhub portfolio list --user <username>` 갱신 |
## Execution Log
### 구현 결정 사항
- `--user` 옵션: `typer.Option(None, ...)` + 수동 None 체크로 exit code 1 보장 (typer required 옵션은 exit code 2이므로 AC-1 불일치)
- username → user_id 조회: `_with_service_and_repo` 콜백 내 `repository.get_user_by_username(user)` 직접 호출
- 존재하지 않는 username: `ValueError` raise → `execute_command`가 exit code 1로 처리
- `_with_service_and_repo`: 기존 `_with_service`와 동일 패턴, callback 인자에 `repository` 추가
### 기존 테스트 수정
- `test_cli_list_renders_table`: `FakeRepo()` → user alice 생성 + `portfolio.owner_user_id` 설정 + `--user alice` 추가
- `test_cli_list_supports_tag_filters`: 동일 패턴 적용
## Validation
```
pytest tests/test_cli.py -k "test_cli_list_renders_table or test_cli_list_supports_tag_filters or test_cli_portfolio_list" -v
```
```
6 passed
```
```
pytest tests/test_cli.py -v
```
```
41 passed in 1.22s (전체 회귀, 기존 테스트 전원 통과)
```
## Remaining Risks
- 없음
@@ -0,0 +1,42 @@
# BAT-003 Index
- Status: release-candidate
- Batch: BAT-003
- Profile: batch-lite
- Created: 2026-05-02
## Included REQ
| REQ | 제목 | Type | Priority |
|-----|------|------|----------|
| REQ-013 | CLI `portfolio list --user` 필수화 및 소유자 기반 조회 | Interface | Medium |
## Source Discovery
- [dcy-003_20260502_portfolio-list-user-required](../../discovery/dcy-003_20260502_portfolio-list-user-required.md)
## Scope Summary
- CLI `pfhub portfolio list` 명령에 `--user <username>` 필수 옵션 추가
- username → owner_user_id(UUID) 변환 로직 추가 (repository.get_user_by_username 직접 호출)
- `PortfolioService.list_portfolios(owner_user_id=...)` 호출 시 소유자 필터 항상 전달
- `--user` 미입력 시 exit code 1 오류 종료, 존재하지 않는 username 시 오류 종료
- README 예제 라인 갱신 (breaking change 안내 포함)
- DB 스키마 변경 없음, HTTP API 변경 없음
## Impacted Area
- Docs:
- `README.md` — `pfhub portfolio list` 예제 라인 `--user <username>` 옵션 추가, breaking change 안내 추가
- Modules:
- `app/cli/commands/portfolio.py` — `list_portfolios` 명령 시그니처 및 서비스 호출부 수정
- Tests:
- `tests/test_cli.py` — AC 1~5 커버 테스트 케이스 4개 추가
## Stage Documents
| Stage | File | Status |
|-------|------|--------|
| Planning | [planning.md](./planning.md) | confirmed |
| Implementation | [implementation.md](./implementation.md) | completed |
| Verification | [verification.md](./verification.md) | verified |
@@ -0,0 +1,89 @@
# BAT-003 Planning
- Status: draft
- Batch: BAT-003
- Profile: batch-lite
## Included REQ
- REQ-013: CLI `portfolio list --user` 필수화 및 소유자 기반 조회
## Out of Scope
- `--all` 관리자 전체 조회 옵션 (후속 Discovery로 분리)
- HTTP API `GET /portfolios` 접근 통제 변경 (현행 유지)
- CLI에 `_ownership_required` 설정 플래그 도입
## 구현 순서
### 1단계: CLI 명령 수정
- `app/cli/commands/portfolio.py` — `list_portfolios` 명령 수정
- 현재 시그니처:
```python
@portfolio_app.command("list")
def list_portfolios(
ctx: typer.Context,
broker: str | None = ...,
status: str | None = ...,
include_tags: ...,
exclude_tags: ...,
match_mode: ...,
)
```
- 변경 내용:
- `user: str = typer.Option(..., "--user", help="Filter by username (required).")` 필수 옵션 추가
- `_with_service` callback 안에서 `repository.get_user_by_username(user)` 호출
- user가 `None`이면 오류 출력 + `raise typer.Exit(code=1)` (typer의 `required=True` 또는 직접 검증)
- 조회 결과가 `None`이면 "사용자를 찾을 수 없습니다" 메시지 + `raise typer.Exit(code=1)`
- `service.list_portfolios(owner_user_id=found_user.id, ...)` 로 호출
- 참고: `_with_service` 헬퍼는 `PortfolioService`만 생성하므로, username → user_id 조회는 callback 내부에서 `repository.get_user_by_username(user)`를 직접 호출한다. callback 인자로 `(service, app_context)` 대신 repository도 사용 가능 — 기존 `_with_service`에서 `repository = app_context.repository_factory(settings)` 반환 객체를 callback에 노출하는 방식으로 처리한다.
구현 패턴 (예시):
```python
def _with_service_and_repo(ctx, callback):
app_context = ctx.obj
repository = app_context.repository_factory(app_context.settings)
service = PortfolioService(repository)
try:
result = execute_command(
app_context.console,
callback=lambda: callback(service, repository, app_context),
)
return result
finally:
if callable(getattr(type(repository), "close", None)):
repository.close()
```
또는 기존 `_with_service` callback 안에서 `service._repository`를 통해 접근하는 방법도 검토한다. 구현 시 결정.
### 2단계: README 수정
- `README.md` — CLI 예제 섹션의 `pfhub portfolio list` 라인을 `pfhub portfolio list --user <username>`으로 변경
- breaking change 안내 문구 추가 (Service token issue policy 아래 또는 CLI 섹션 내)
### 3단계: 테스트 추가
- `tests/test_cli.py` — 4개 테스트 케이스 추가:
1. `test_cli_portfolio_list_without_user_exits_error`
- FakeRepo 준비, `--user` 없이 호출 → `exit_code == 1`, 오류 메시지 포함 확인
2. `test_cli_portfolio_list_with_valid_user_filters_by_owner`
- FakeRepo에 복수 사용자 + 포트폴리오 준비, `--user alice` 호출 → alice 소유 항목만 반환
3. `test_cli_portfolio_list_with_unknown_user_exits_error`
- 존재하지 않는 username으로 호출 → `exit_code == 1`, "찾을 수 없습니다" 포함 확인
4. `test_cli_portfolio_list_in_memory_mode_filters_owner`
- in-memory 모드에서도 동일하게 소유자 필터 적용 확인 (AC 4)
## Dependencies
- Internal:
- `app/repositories/portfolio_repository.py` — `get_user_by_username` Protocol stub 존재 확인 완료 (line 16)
- `app/repositories/postgres.py` — `get_user_by_username` 구현 존재 확인 완료 (line 419)
- `tests/test_cli.py` — `FakeRepo.get_user_by_username` 존재 확인 완료 (line 109)
- External: 없음
## Design Gate
- Design Required Before Implementation: no
- Reason: DB 스키마 변경 없음, HTTP API 변경 없음. CLI 옵션 추가 + repository 메서드 직접 호출 패턴은 기존 service-token issue 구현과 동일 수준. 구조적 결정 사항 없음.
@@ -0,0 +1,72 @@
# BAT-003 Verification
- Status: verified
- Batch: BAT-003
- Profile: batch-lite
## Architecture Impact Check
- Design Gate (from planning.md): no — DB 스키마 변경 없음, HTTP API 변경 없음
- Actual Architecture Impact: none confirmed — `app/cli/commands/portfolio.py` 내부 헬퍼 추가, Protocol stub/구현체 변경 없음
- Baseline Document Sync Required: no
## Acceptance Mapping
### AC 1: `--user` 없이 실행 → exit code 1 + 오류 메시지
| 항목 | 내용 |
|------|------|
| Evidence | `test_cli_portfolio_list_without_user_exits_error` |
| Verification | `runner.invoke(app, ["portfolio", "list"])` → `exit_code == 1`, `"--user" in result.stdout` |
| Result | ✅ PASSED |
### AC 2: `--user alice` → alice 소유 포트폴리오만 반환
| 항목 | 내용 |
|------|------|
| Evidence | `test_cli_portfolio_list_with_valid_user_filters_by_owner` |
| Verification | alice 포트폴리오만 등록 → alice 조회 시 `005930:tv` 포함, bob 조회 시 미포함 확인 |
| Result | ✅ PASSED |
### AC 3: 존재하지 않는 username → 오류 메시지 + exit code 1
| 항목 | 내용 |
|------|------|
| Evidence | `test_cli_portfolio_list_with_unknown_user_exits_error` |
| Verification | `--user nobody` → `exit_code == 1`, `"nobody" in result.stdout` |
| Result | ✅ PASSED |
### AC 4: in-memory 모드에서도 동일 동작
| 항목 | 내용 |
|------|------|
| Evidence | `test_cli_portfolio_list_in_memory_mode_filters_owner` |
| Verification | FakeRepo(in-memory) 사용, `--user alice` → alice 소유 포트폴리오 반환 확인 |
| Result | ✅ PASSED |
### AC 5: 오류 형식이 기존 CLI와 일관성 유지
| 항목 | 내용 |
|------|------|
| Evidence | 코드 검토 — `execute_command`의 `ValueError` 핸들러(`console.print + raise typer.Exit(code=1)`)와 동일 패턴 사용 |
| Verification | `--user` 미입력 → `ctx.obj.console.print(...)` + `raise typer.Exit(code=1)`, 미등록 user → `ValueError` → `execute_command` 핸들러 동일 경로 |
| Result | ✅ 충족 |
## Test Execution
```
pytest tests/test_cli.py -v
41 passed in 1.22s
```
- 신규 케이스 6개 (`test_cli_portfolio_list_*`, 수정된 `test_cli_list_renders_table`, `test_cli_list_supports_tag_filters`) 포함 전체 통과
- 기존 37개 케이스 회귀 없음
## Blocking Issues
- 없음
## Result
- Release Ready: yes
- Notes: batch-lite, design 없음, planning의 Design Gate = no, architecture impact = none confirmed
+1
View File
@@ -10,3 +10,4 @@
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| BAT-001 | standard | release-candidate | REQ-004, REQ-005, REQ-006, REQ-007, REQ-008, REQ-009, REQ-010 | dcy-002 | [bat-001_20260502_trex-basic-v1-api-contract](./bat-001_20260502_trex-basic-v1-api-contract/) | | BAT-001 | standard | release-candidate | REQ-004, REQ-005, REQ-006, REQ-007, REQ-008, REQ-009, REQ-010 | dcy-002 | [bat-001_20260502_trex-basic-v1-api-contract](./bat-001_20260502_trex-basic-v1-api-contract/) |
| BAT-002 | batch-lite | release-candidate | REQ-012 | minor-change | [bat-002_20260502_service-token-rotate-cli](./bat-002_20260502_service-token-rotate-cli/) | | BAT-002 | batch-lite | release-candidate | REQ-012 | minor-change | [bat-002_20260502_service-token-rotate-cli](./bat-002_20260502_service-token-rotate-cli/) |
| BAT-003 | batch-lite | release-candidate | REQ-013 | dcy-003 | [bat-003_20260502_portfolio-list-user-required](./bat-003_20260502_portfolio-list-user-required/) |
@@ -0,0 +1,191 @@
# 0. 문서 상태
이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다.
- 상태: confirmed
- 문서 ID: dcy-003_20260502_portfolio-list-user-required
- 이슈명: CLI `pfhub portfolio list` — `--user` 옵션 필수화 및 소유자 기반 조회 강제
- 작성 시각(KST): 2026-05-02T00:00:00+09:00
- 마지막 갱신 시각(KST): 2026-05-02T09:10:00+09:00
- 대체됨: 없음
- 후속 Discovery 참조: 없음
- 생성된 REQ 참조:
- [REQ-013](../../docs/srs/Interface/req-013_portfolio-list-user-required.md) — CLI `portfolio list --user` 필수화 및 소유자 기반 조회
# 1. 계획 상태 요약
현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다.
- 연계 문서 현황:
- 관련 점수 파일: 미생성
- 관련 TODO/메모: 없음
- 해석:
- CLI `pfhub portfolio list`는 현재 `--user` 옵션이 없어 `owner_user_id=None`으로 서비스를 호출하며, DB에 저장된 전체 포트폴리오를 반환한다. HTTP API는 `_ownership_required` 설정 및 ADMIN 여부에 따라 소유자 필터를 적용하지만 CLI에는 동일한 접근 통제가 존재하지 않는다.
- 이번 Discovery에서는 CLI `portfolio list` 명령에 `--user` 옵션을 필수로 지정하고, 해당 username으로 `owner_user_id`를 조회하여 소유자 기반 필터 조회를 강제하는 방향을 정의한다.
# 2. 요구사항 판정 결과
이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다.
- 판정: 신규
- 근거
- dcy-001(실DB 마이그레이션 검증)과 dcy-002(trex-basic API 계약)는 모두 CLI `portfolio list` 인터페이스 및 소유자 필터 정책을 다루지 않는다.
- 기존 REQ 목록(`docs/srs/index.md`, REQ-001~REQ-012)에 CLI `portfolio list` 접근 통제 관련 요구사항이 존재하지 않는다.
# 3. 문제점의 요약
문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. Discovery의 핵심 맥락을 가장 먼저 이해할 수 있어야 한다.
- 현재 상태
- `app/cli/commands/portfolio.py`의 `list_portfolios` 명령은 `--user` 옵션이 없으며, `owner_user_id=None`으로 `PortfolioService.list_portfolios`를 호출한다.
- `owner_user_id=None`이면 `app/repositories/postgres.py`의 `list_portfolios`가 소유자 WHERE 절을 추가하지 않아 전체 포트폴리오를 반환한다.
- HTTP API(`app/api/portfolios.py`)는 `_ownership_required` 설정 플래그와 ADMIN 권한 여부에 따라 소유자 필터를 조건부 적용하지만, CLI에는 동일한 로직이 없다.
- 기대 상태
- `pfhub portfolio list --user <username>` 형태로 `--user`를 필수 옵션으로 지정한다.
- `--user` 없이 실행하면 명확한 오류 메시지와 함께 종료된다.
- `--user`로 전달된 username을 users 테이블에서 조회하여 `owner_user_id`(UUID)로 변환한 뒤 조회한다.
- 이해관계자
- CLI 운영자(관리자 계정으로 pfhub CLI를 사용하는 사람)
- 백엔드 개발자(CLI 인터페이스 및 서비스 레이어 관리자)
- 제약사항
- CLI는 기존 `PortfolioService.list_portfolios`와 `UserService` (또는 user repository)를 재사용해야 한다. 별도 API 호출 없이 직접 DB를 통해 username → user_id 변환이 이루어진다.
- `pfhub_DB_ENABLED=false`(in-memory) 모드에서도 동일한 인터페이스 검증이 적용되어야 한다.
## 주요 사용자 시나리오
사용자 또는 운영자가 실제로 어떤 상황에서 이 변경을 필요로 하는지 대표 시나리오를 적는다.
- 관리자가 특정 사용자의 포트폴리오만 확인하기 위해 `pfhub portfolio list --user alice`를 실행한다.
- 관리자가 `--user` 없이 `pfhub portfolio list`를 실행했을 때 "Error: --user 옵션이 필요합니다" 형태의 메시지를 받고 올바른 사용법을 인지한다.
- 존재하지 않는 username으로 `pfhub portfolio list --user unknown`을 실행하면 사용자를 찾을 수 없다는 오류 메시지와 함께 종료된다.
# 4. 이번에 정의할 변경
이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. 기능, 구조, 규칙 변경을 함께 포함해도 된다.
- CLI `portfolio list` 명령에 `--user <username>` 필수 옵션을 추가하고, 미입력 시 에러로 종료한다.
- CLI 내부에서 username → owner_user_id(UUID) 변환 로직을 추가한다(user repository 또는 user service 직접 조회).
- `PortfolioService.list_portfolios` 호출 시 변환된 `owner_user_id`를 항상 전달하도록 CLI 호출부를 수정한다.
## 영향 범위
변경이 미칠 코드/문서/운영 범위를 명시해 REQ 정제와 batch planning에서 추정 누락을 줄인다.
- 코드 영향:
- `app/cli/commands/portfolio.py` — `list_portfolios` 명령 시그니처 및 서비스 호출부 수정
- `app/repositories/postgres.py` — `get_user_by_username`(line 419) 직접 호출. `UserService`에는 해당 메서드 없음 — repository를 통해 직접 조회
- 문서 영향:
- `README.md` — `pfhub portfolio list` 예제 라인에 `--user` 옵션 추가
- `docs/runtime-flows.md` — CLI portfolio list 흐름 갱신(있을 경우)
- 운영 영향:
- 기존 스크립트에서 `pfhub portfolio list`를 `--user` 없이 호출하면 즉시 에러로 변경됨 → 스크립트 마이그레이션 필요
# 5. 요구사항 목록
실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다.
## 기능 요구사항
- FR-1: CLI `pfhub portfolio list` 명령에 `--user <username>` 옵션을 필수로 추가한다. `--user` 없이 실행하면 오류 메시지를 출력하고 비정상 종료(exit code 1)한다.
- FR-2: CLI 내부에서 `--user`로 전달받은 username을 user 저장소에서 조회하여 `owner_user_id`(UUID)를 얻고, 이를 `PortfolioService.list_portfolios`의 `owner_user_id` 인자로 전달한다.
- FR-3: `--user`로 전달된 username이 존재하지 않으면 "사용자를 찾을 수 없습니다" 형태의 오류 메시지를 출력하고 비정상 종료한다.
## 비기능 요구사항
- NFR-1: username → user_id 변환 실패 메시지는 기존 CLI 오류 출력 형식(Rich Console 또는 typer.echo)과 일관성을 유지한다.
- NFR-2: `--user` 필수화는 in-memory 모드(`pfhub_DB_ENABLED=false`)에서도 동일하게 적용되어야 하며, 테스트에서 검증 가능해야 한다.
## 범위 경계
이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다.
- In Scope
- CLI `portfolio list` 명령의 `--user` 필수 옵션화
- username → owner_user_id 변환 로직 (user repo/service 직접 조회)
- README 예제 수정
- Out of Scope
- HTTP API `GET /portfolios`의 접근 통제 변경 (현행 유지)
- `portfolio list`의 `--all` 관리자 전체 조회 옵션 추가 (후속 Discovery로 분리)
- CLI에 HTTP API 방식의 `_ownership_required` 설정 플래그 도입 (이번 반복 외)
# 6. 리스크/가정 목록
불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다.
## 리스크
- R-1 (Medium): 기존 스크립트 또는 자동화에서 `pfhub portfolio list`를 `--user` 없이 호출하는 경우, 변경 후 즉시 에러로 중단된다.
- 완화 방안: README에 변경 사항을 명확히 기재하고, 커밋 메시지에 breaking change 표기.
- R-2 (Low): ~~in-memory 모드에서 username 조회를 위한 user repo 인터페이스가 충분히 구현되어 있지 않을 수 있다.~~ → **해소**: `FakeRepo.get_user_by_username`(`tests/test_cli.py:109`)과 `PortfolioRepository` Protocol stub(`portfolio_repository.py:16`) 모두 존재 확인.
- 완화 방안: 해소 완료 — 추가 구현 불필요.
## 가정
- A-1: CLI는 관리자 권한으로만 실행된다는 기존 설계를 유지한다. 따라서 `--user`로 타인의 포트폴리오도 조회 가능하며 CLI 레벨의 추가 권한 검사는 이번 범위에서 제외한다.
- A-2: `get_user_by_username`이 이미 `PortfolioRepository` Protocol(`portfolio_repository.py:16`), `PostgresRepo`(`postgres.py:419`), `FakeRepo`(`tests/test_cli.py:109`) 모두에 구현되어 있음이 확인되었다.
- A-3: `PortfolioService.list_portfolios`의 시그니처는 변경하지 않는다. CLI 호출부에서만 `owner_user_id`를 전달한다.
## 오픈 질문
- OQ-1: in-memory FakeRepo에서 username → user_id 조회 메서드가 없는 경우 테스트를 어떻게 구성할지.
- 상태: Answered
- 처리 방안: `tests/test_cli.py`의 `FakeRepo`에 `get_user_by_username(username) -> User | None` 메서드가 이미 구현되어 있음(line 109). `_users` dict에서 username 일치 항목을 선형 탐색하여 반환한다. 추가 stub 없이 기존 메서드를 그대로 사용하면 된다. `app/repositories/postgres.py`에도 동일 시그니처(line 419)가 구현되어 있어 in-memory/DB 모드 모두 지원 가능하다.
- 종료 조건: 저장소 현황 검토 완료 — FakeRepo 및 PostgresRepo 양쪽 모두 `get_user_by_username` 존재 확인됨.
### 오픈 질문 처리 기준
- `Open`: 아직 답이 없고 다음 단계 진행에 영향이 있는 상태다. 문서에 유지한다.
- `Answered`: 답이 확정된 상태다. 관련 본문/요구사항에 반영한 뒤 오픈 질문 목록에서는 제거하거나 resolved 기록으로 이동한다.
- `Deferred`: 지금 단계에서 결정하지 않고 다음 단계 또는 후속 이슈로 넘기는 상태다. 넘길 대상과 이유를 반드시 적는다.
- `Dropped`: 더 이상 유효하지 않은 질문이다. 삭제 사유를 짧게 남기고 목록에서 제거한다.
- 처리 원칙 1: 질문만 남기지 말고 반드시 `상태`, `처리 방안`, `종료 조건`을 함께 적는다.
- 처리 원칙 2: 답이 나온 질문은 본문 반영 없이 단순 삭제하지 않는다. 반영 위치를 먼저 갱신한다.
- 처리 원칙 3: High 영향 질문은 `Deferred` 처리 후 처리 방안에 REQ 인계 추적 방식을 명시한다.
# 7. 초기 성공 기준
이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다.
- S-1: `pfhub portfolio list` (without `--user`) 실행 시 exit code 1과 함께 오류 메시지가 출력된다.
- S-2: `pfhub portfolio list --user alice` 실행 시 alice의 `owner_user_id`로 필터된 포트폴리오 목록만 반환된다.
- S-3: 존재하지 않는 username으로 실행 시 "사용자를 찾을 수 없습니다" 형태의 오류 메시지와 함께 비정상 종료된다.
## 측정 방식 및 데이터 출처
각 성공 기준을 어떻게 측정할지와 어떤 데이터나 문서를 근거로 쓸지 적는다.
- S-1 측정 방식: pytest에서 `CliRunner.invoke`로 `--user` 없는 호출의 exit_code == 1 확인 / 데이터 출처: `tests/test_cli.py`
- S-2 측정 방식: FakeRepo/DB에 복수 사용자 포트폴리오 세팅 후 CLI 결과에 alice 소유 항목만 존재하는지 검증 / 데이터 출처: `tests/test_cli.py`
- S-3 측정 방식: 존재하지 않는 username 입력 시 exit_code != 0 및 오류 문자열 포함 여부 검증 / 데이터 출처: `tests/test_cli.py`
# 8. REQ로 넘기기 전 확인 체크
REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다.
- [x] 범위(In Scope/Out of Scope) 확정
- 기준: `--user` 필수화 In Scope, `--all` 옵션/HTTP API 변경 Out of Scope — #5에 명시. confirm-discovery 실행으로 확정.
- [x] 성공 지표의 측정 방식/데이터 출처 확정
- 기준: S-1/S-2/S-3 각각 pytest `CliRunner.invoke` 기반 검증 케이스로 기술됨. #7 참조.
- [x] High 영향 리스크 우선순위 및 해소 책임자 지정
- 기준: 리스크 중 High 등급 없음(R-1: Medium, R-2: Low → 해소). R-1 완화 방안(README 명기, breaking change 표기)을 문서에 명시.
- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
- 기준: OQ-1 Answered 처리 완료.
- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정
- 기준: 프로젝트 오너가 confirm-discovery 실행으로 확정. 2026-05-02.
# 9. 파일 처리 결과
이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. 이후 추적과 리뷰 시 근거로 사용한다.
- 처리 결과: 생성
- 생성 경로: docs/discovery/dcy-003_20260502_portfolio-list-user-required.md
- 참조 문서:
- app/cli/commands/portfolio.py
- app/services/portfolio_service.py
- app/repositories/postgres.py
- app/api/portfolios.py
# 10. 사용자 결정 필요 항목 요약
사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다.
## DECIDE
- ~~{{DECIDE_ALL_OPTION}}~~ → Resolved: `--all` 옵션은 #5 Out of Scope로 결정됨. 후속 Discovery로 분리.
## CONFIRM
- ~~{{CONFIRM_BREAKING_CHANGE}}~~ → Resolved: R-1(Medium)에 문서화됨. 완화 방안(README 명기, breaking change 표기) 채택. confirm-discovery 실행으로 인지 확인.
- ~~{{CONFIRM_SCOPE}}~~ → Resolved: In Scope / Out of Scope가 #5에 명시됨. confirm-discovery 실행으로 확정.
## DATA
- ~~{{DATA_FAKEREPO_USERNAME_LOOKUP}}~~ → Answered: `FakeRepo.get_user_by_username`(`tests/test_cli.py:109`) 및 `PostgresRepo.get_user_by_username`(`app/repositories/postgres.py:419`) 모두 존재 확인 완료.
# 11. Discovery Freeze
REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다.
- Handoff Decision: 승인
- Handoff Rationale: 저장소 현황 재확인 완료. 모든 C 유형 플레이스홀더 해소, OQ Open 없음, #8 체크리스트 전항 완료. REQ 초안 작성 준비 완료.
- Ready for REQ Drafting: true
- Confirmed By: Justin
- Confirmed At (KST): 2026-05-02T09:00:00+09:00
- 범위 참조: [# 5. 요구사항 목록](#5-요구사항-목록)
- 성공 기준 참조: [# 7. 초기 성공 기준](#7-초기-성공-기준)
- 리스크 참조: [# 6. 리스크/가정 목록](#6-리스크가정-목록)
+1
View File
@@ -8,3 +8,4 @@
|---|---|---|---|---| |---|---|---|---|---|
| dcy-001_20260430_real-db-migration-validation-and-operations-readiness | 20260430 | 실DB 마이그레이션 검증 및 운영 준비 | [Discovery](./dcy-001_20260430_real-db-migration-validation-and-operations-readiness.md) | confirmed | | dcy-001_20260430_real-db-migration-validation-and-operations-readiness | 20260430 | 실DB 마이그레이션 검증 및 운영 준비 | [Discovery](./dcy-001_20260430_real-db-migration-validation-and-operations-readiness.md) | confirmed |
| dcy-002_20260502_trex-basic-consumer-api-contract | 20260502 | trex-basic Consumer-Driven Contract 반영 — service-token runtime_id 발급 및 v1 API 엔드포인트 구현 | [Discovery](./dcy-002_20260502_trex-basic-consumer-api-contract.md) | confirmed | | dcy-002_20260502_trex-basic-consumer-api-contract | 20260502 | trex-basic Consumer-Driven Contract 반영 — service-token runtime_id 발급 및 v1 API 엔드포인트 구현 | [Discovery](./dcy-002_20260502_trex-basic-consumer-api-contract.md) | confirmed |
| dcy-003_20260502_portfolio-list-user-required | 20260502 | CLI `pfhub portfolio list` — `--user` 옵션 필수화 및 소유자 기반 조회 강제 | [Discovery](./dcy-003_20260502_portfolio-list-user-required.md) | confirmed |
@@ -0,0 +1,143 @@
# REQ-013: CLI `portfolio list --user` 필수화 및 소유자 기반 조회
- Status: Implemented
- Type: Interface
- Priority: Medium
- Owner: repository-maintainers
## Intent
- CLI `pfhub portfolio list`는 현재 `--user` 옵션이 없어 `owner_user_id=None`으로 호출되며 전체 포트폴리오를 반환한다. 이 동작은 소유자 구분 없이 DB 전체를 노출하는 문제가 있다.
- `--user <username>` 옵션을 필수로 지정하여 CLI 실행 시 항상 특정 사용자의 포트폴리오만 조회하도록 강제한다.
- username을 `owner_user_id`(UUID)로 변환하는 로직을 CLI 내부에 추가하여, `PortfolioService.list_portfolios` 호출 시 소유자 필터가 항상 적용되게 한다.
## Requirement
- CLI `pfhub portfolio list` 명령은 `--user <username>` 옵션을 필수로 받아야 한다.
- `--user` 없이 실행하면 오류 메시지를 출력하고 exit code 1로 종료해야 한다.
- `--user`로 전달된 username을 user repository(`get_user_by_username`)로 조회하여 `owner_user_id`(UUID)를 얻고, 이를 `PortfolioService.list_portfolios(owner_user_id=...)` 호출에 전달해야 한다.
- 존재하지 않는 username이 전달되면 기존 CLI 오류 출력 형식(Rich Console 또는 typer.echo)과 일관된 오류 메시지를 출력하고 비정상 종료해야 한다.
## Acceptance Criteria
1. Given `--user` 옵션 없이 `pfhub portfolio list`를 실행할 때, When 명령이 실행되면, Then 오류 메시지("--user 옵션이 필요합니다" 또는 동등한 내용)가 출력되고 exit code 1로 종료된다.
2. Given 유효한 username `alice`로 `pfhub portfolio list --user alice`를 실행할 때, When 명령이 실행되면, Then `alice`의 `owner_user_id`로 필터된 포트폴리오 목록만 반환되고 다른 사용자 소유 포트폴리오는 포함되지 않는다.
3. Given 존재하지 않는 username으로 `pfhub portfolio list --user unknown`을 실행할 때, When 명령이 실행되면, Then "사용자를 찾을 수 없습니다" 형태의 오류 메시지가 출력되고 exit code 1로 종료된다.
4. Given in-memory 모드(`pfhub_DB_ENABLED=false`)에서 `pfhub portfolio list --user alice`를 실행할 때, When 명령이 실행되면, Then DB 모드와 동일하게 `alice` 소유 포트폴리오만 반환된다.
5. Given 오류 발생 시, When 오류 메시지가 출력될 때, Then 메시지 형식은 기존 CLI 오류 출력 형식(Rich Console stderr 또는 typer.echo)과 일관성을 유지한다.
## Impacted Area
- Docs:
- `README.md` — `pfhub portfolio list` 예제 라인에 `--user <username>` 옵션 추가 및 breaking change 안내
- Modules:
- `app/cli/commands/portfolio.py` — `list_portfolios` 명령 시그니처에 `--user` 필수 옵션 추가, username → owner_user_id 변환 로직 추가, `PortfolioService.list_portfolios` 호출 시 `owner_user_id` 전달
- `app/repositories/portfolio_repository.py` — `get_user_by_username` Protocol stub 기존 존재 확인(line 16), 추가 변경 불필요
- `app/repositories/postgres.py` — `get_user_by_username` 기존 구현 확인(line 419), 추가 변경 불필요
- Tests:
- `tests/test_cli.py` — AC 1~5 커버 테스트 케이스 추가:
- `test_cli_portfolio_list_without_user_exits_error`
- `test_cli_portfolio_list_with_valid_user_filters_by_owner`
- `test_cli_portfolio_list_with_unknown_user_exits_error`
- `test_cli_portfolio_list_in_memory_mode_works`
## Notes
- `PortfolioService.list_portfolios` 시그니처는 변경하지 않는다. CLI 호출부에서만 `owner_user_id`를 전달한다 (A-3, dcy-003).
- HTTP API `GET /portfolios`의 접근 통제는 이번 REQ 범위 밖이다 (Out of Scope).
- `--all` 관리자 전체 조회 옵션은 이번 REQ에 포함하지 않는다. 후속 Discovery로 분리한다.
- username → owner_user_id 변환은 `repository.get_user_by_username(username)` 직접 호출로 구현한다. `UserService`에는 해당 메서드가 없음을 확인하였다.
- 기존 `_with_service` 헬퍼는 `PortfolioService`만 생성한다. username 조회를 위해 별도로 repository를 직접 사용하거나 `_with_service` callback 안에서 repository 접근이 필요하다. 구현 시 `app_context.repository_factory(settings)` 반환 객체를 통해 `get_user_by_username`을 호출하는 방식으로 처리한다.
- Source Discovery: [dcy-003](../../discovery/dcy-003_20260502_portfolio-list-user-required.md)
## Change Log
### CHG-20260502-03
- Date: 2026-05-02
- Author: repository-maintainers
#### Change Summary
- Status Approved → Implemented. BAT-003(release-candidate) verification evidence 확인 완료.
#### Intent
- confirm-req-implemented 실행. AC 1~5 전원 evidence 연결 확인, Blocking Issues 없음.
#### Evidence Batch
- Batch: BAT-003 (release-candidate)
- Verification: docs/batches/bat-003_20260502_portfolio-list-user-required/verification.md
- Test result: 41 passed (pytest tests/test_cli.py)
#### Acceptance Criteria Delta
- 없음
#### Impacted Area Delta
- Docs: 없음
- Modules: 없음
- Tests: 없음
#### Delivery Trace Delta
- Batches: BAT-003 (release-candidate)
- Releases: none (draft-release 대기 중)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: draft-release
### CHG-20260502-02
- Date: 2026-05-02
- Author: repository-maintainers
#### Change Summary
- Status Proposed → Approved. 승인 게이트 통과. 필수 필드, AC 5개, Impacted Area, Change Log 모두 충족.
#### Intent
- confirm-req 실행으로 구현 delivery 입력 준비 확정.
#### Acceptance Criteria Delta
- Added: 없음
- Updated: 없음
- Removed: 없음
#### Impacted Area Delta
- Docs: 없음
- Modules: 없음
- Tests: 없음
#### Delivery Trace Delta
- Batches: none
- Releases: none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: batch 생성 진행 (draft-batch)
### CHG-20260502-01
- Date: 2026-05-02
- Author: repository-maintainers
#### Change Summary
- REQ-013 최초 생성 (Proposed). dcy-003 FR-1, FR-2, FR-3 통합. NFR-1, NFR-2는 AC 5에 흡수.
#### Intent
- dcy-003 확정 Discovery를 REQ backlog로 정규화.
#### Acceptance Criteria Delta
- Added:
- AC 1~5 신규 작성
#### Impacted Area Delta
- Docs:
- README.md breaking change 안내 추가 항목
- Modules:
- app/cli/commands/portfolio.py 신규 변경
- Tests:
- tests/test_cli.py 4개 테스트 케이스 신규 추가
#### Delivery Trace Delta
- Batches: none (batch 미생성)
- Releases: none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 실행 후 batch 계획 수립
+5 -1
View File
@@ -74,7 +74,7 @@
## Next Requirement ID ## Next Requirement ID
- Current: `REQ-013` - Current: `REQ-014`
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. - Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
## Requirement Register ## Requirement Register
@@ -93,9 +93,13 @@
| REQ-010 | credential_payload Fernet 암호화 및 startup validation | Non-Functional | Approved | High | repository-maintainers | [REQ-010](./Non-Functional/req-010_credential-payload-fernet-encryption.md) | | REQ-010 | credential_payload Fernet 암호화 및 startup validation | Non-Functional | Approved | High | repository-maintainers | [REQ-010](./Non-Functional/req-010_credential-payload-fernet-encryption.md) |
| REQ-011 | trex-basic 계약 반영을 위한 baseline 문서 갱신 | Documentation | Approved | Medium | repository-maintainers | [REQ-011](./Documentation/req-011_baseline-docs-update-for-trex-basic-contract.md) | | REQ-011 | trex-basic 계약 반영을 위한 baseline 문서 갱신 | Documentation | Approved | Medium | repository-maintainers | [REQ-011](./Documentation/req-011_baseline-docs-update-for-trex-basic-contract.md) |
| REQ-012 | service-token rotate CLI 명령 구현 | Interface | Approved | Medium | repository-maintainers | [REQ-012](./Interface/req-012_service-token-rotate-cli.md) | | REQ-012 | service-token rotate CLI 명령 구현 | Interface | Approved | Medium | repository-maintainers | [REQ-012](./Interface/req-012_service-token-rotate-cli.md) |
| REQ-013 | CLI `portfolio list --user` 필수화 및 소유자 기반 조회 | Interface | Implemented | Medium | repository-maintainers | [REQ-013](./Interface/req-013_portfolio-list-user-required.md) |
## Recent Change Log Summary ## Recent Change Log Summary
- 2026-05-02: REQ-013 Implemented 전환 — BAT-003 verification evidence 확인 (41 passed)
- 2026-05-02: REQ-013 Approved 전환 — CLI `portfolio list --user` 필수화 및 소유자 기반 조회 (source Discovery: dcy-003)
- 2026-05-02: REQ-013 Proposed 생성 — CLI `portfolio list --user` 필수화 및 소유자 기반 조회 (source Discovery: dcy-003)
- 2026-05-02: REQ-012 Approved 직행 (minor-change) — service-token rotate CLI 명령 구현 - 2026-05-02: REQ-012 Approved 직행 (minor-change) — service-token rotate CLI 명령 구현
- 2026-05-02: REQ-011 Approved 전환 — trex-basic 계약 반영을 위한 baseline 문서 갱신 (source Discovery: dcy-002) - 2026-05-02: REQ-011 Approved 전환 — trex-basic 계약 반영을 위한 baseline 문서 갱신 (source Discovery: dcy-002)
- 2026-05-02: REQ-010 Approved 전환 — credential_payload Fernet 암호화 및 startup validation (source Discovery: dcy-002) - 2026-05-02: REQ-010 Approved 전환 — credential_payload Fernet 암호화 및 startup validation (source Discovery: dcy-002)
+68 -4
View File
@@ -458,10 +458,14 @@ def _portfolio() -> Portfolio:
def test_cli_list_renders_table() -> None: def test_cli_list_renders_table() -> None:
repo = FakeRepo(_portfolio()) repo = FakeRepo()
user = repo.create_user("alice", permission="read")
p = _portfolio()
p.owner_user_id = user.id
repo._portfolio = p
runner = build_runner(repo) runner = build_runner(repo)
result = runner.invoke(runner.app, ["portfolio", "list"]) result = runner.invoke(runner.app, ["portfolio", "list", "--user", "alice"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "005930:tv" in result.stdout assert "005930:tv" in result.stdout
@@ -820,11 +824,15 @@ def test_cli_tag_add_and_list() -> None:
def test_cli_list_supports_tag_filters() -> None: def test_cli_list_supports_tag_filters() -> None:
repo = FakeRepo(_portfolio()) repo = FakeRepo()
user = repo.create_user("alice", permission="read")
p = _portfolio()
p.owner_user_id = user.id
repo._portfolio = p
repo.add_tag("005930:tv", tag="grade:A") repo.add_tag("005930:tv", tag="grade:A")
runner = build_runner(repo) runner = build_runner(repo)
result = runner.invoke(runner.app, ["portfolio", "list", "--include-tag", "grade:A", "--match-mode", "AND"]) result = runner.invoke(runner.app, ["portfolio", "list", "--include-tag", "grade:A", "--match-mode", "AND", "--user", "alice"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "005930:tv" in result.stdout assert "005930:tv" in result.stdout
@@ -1166,3 +1174,59 @@ def test_cli_service_token_rotate_can_abort() -> None:
token = repo.get_service_token_by_name("permanent") token = repo.get_service_token_by_name("permanent")
assert token is not None assert token is not None
assert token.token_prefix == "pref" assert token.token_prefix == "pref"
# ---------------------------------------------------------------------------
# portfolio list --user (REQ-013)
# ---------------------------------------------------------------------------
def test_cli_portfolio_list_without_user_exits_error() -> None:
repo = FakeRepo()
runner = build_runner(repo)
result = runner.invoke(runner.app, ["portfolio", "list"])
assert result.exit_code == 1
assert "--user" in result.stdout
def test_cli_portfolio_list_with_valid_user_filters_by_owner() -> None:
repo = FakeRepo()
alice = repo.create_user("alice", permission="read")
bob = repo.create_user("bob", permission="read")
alice_portfolio = _portfolio()
alice_portfolio.owner_user_id = alice.id
repo._portfolio = alice_portfolio
runner = build_runner(repo)
result_alice = runner.invoke(runner.app, ["portfolio", "list", "--user", "alice"])
result_bob = runner.invoke(runner.app, ["portfolio", "list", "--user", "bob"])
assert result_alice.exit_code == 0
assert "005930:tv" in result_alice.stdout
assert result_bob.exit_code == 0
assert "005930:tv" not in result_bob.stdout
def test_cli_portfolio_list_with_unknown_user_exits_error() -> None:
repo = FakeRepo()
runner = build_runner(repo)
result = runner.invoke(runner.app, ["portfolio", "list", "--user", "nobody"])
assert result.exit_code == 1
assert "nobody" in result.stdout
def test_cli_portfolio_list_in_memory_mode_filters_owner() -> None:
repo = FakeRepo()
user = repo.create_user("alice", permission="read")
p = _portfolio()
p.owner_user_id = user.id
repo._portfolio = p
runner = build_runner(repo)
result = runner.invoke(runner.app, ["portfolio", "list", "--user", "alice"])
assert result.exit_code == 0
assert "005930:tv" in result.stdout