test(req-001): add missing dividend verification regressions and tighten catalog mappings

Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-09-09 12:08:31 +00:00
committed by GitHub
co-authored by genichin
parent f193aa6da3
commit 1015b465bb
3 changed files with 221 additions and 22 deletions
@@ -0,0 +1,25 @@
# REQ-001 Provenance Reapproval Request
- 대상 requirement: `REQ-001` (`docs/requirements/req-001_manual-cash-dividend.md`)
- 현재 canonical SHA-256: `b9498b5d9b6dd4638d701f68273dbbde1a37dbbaa74d1d3c9923fb1702b6f076`
- 상태: 승인된 canonical 본문은 변경하지 않음
## 요청 배경
`REQ-001` 승인 기록의 문구(`OQ-001 선택지 A`)가 본문 계약(`tax` 허용) 및 이슈 #31의 확정 OQ-001(B)와 provenance 상 충돌한다는 리뷰 코멘트가 확인되었다.
## 요청 사항
승인된 canonical 파일 자체를 구현 PR에서 직접 수정하지 않고, 아래 절차 중 하나를 요청한다.
1. `REQ-001` 한정 1회 예외를 승인하여 provenance 문구를 정정하고 재승인한다.
2. 또는 별도 canonical artifact(새 revision)로 provenance 정정본을 생성하고 재승인한다.
## 정정 대상 문구(제안)
- 기존: `이 대화에서 REQ-001 승인과 OQ-001 선택지 A를 명시적으로 확정함`
- 제안: `이 대화에서 REQ-001 승인과 비-DIVIDEND confirm_duplicate=false 허용/true HTTP 422 결정을 명시적으로 확정함`
## 비고
본 문서는 절차 요청 artifact이며, `REQ-001` canonical bytes/revision/status를 직접 변경하지 않는다.
+17 -22
View File
@@ -3,47 +3,43 @@
Test ID: TC-REQ-001-001
Verifies:
- REQ-001.01-AC01
- REQ-001.02-AC01
Test reference: `tests/test_v1_portfolios_manual_ledger.py::test_dividend_updates_only_profit_buffer_and_records_executed_at_and_tax`, `tests/test_v1_portfolios_manual_ledger.py::test_dividend_requires_executed_at_and_rejects_fee_field`, `tests/test_v1_portfolios_manual_ledger.py::test_dividend_rejects_sub_minimum_numeric_precision`, `tests/test_v1_portfolios_manual_ledger.py::test_dividend_rejects_amount_exceeding_numeric_20_8`
Test reference: `tests/test_v1_portfolios_manual_ledger.py::test_dividend_updates_only_profit_buffer_and_records_executed_at_and_tax`, `tests/test_v1_portfolios_manual_ledger.py::test_dividend_tax_defaults_to_zero_when_omitted`
Automated: true
Test ID: TC-REQ-001-002
Verifies:
- REQ-001.02-AC01
Test reference: `tests/test_v1_portfolios_manual_ledger.py::test_dividend_requires_executed_at_and_rejects_fee_field`
- REQ-001.01-AC02
Test reference: `tests/test_v1_portfolios_manual_ledger.py::test_dividend_requires_executed_at_and_rejects_fee_field`, `tests/test_v1_portfolios_manual_ledger.py::test_dividend_rejects_sub_minimum_numeric_precision`, `tests/test_v1_portfolios_manual_ledger.py::test_dividend_rejects_amount_exceeding_numeric_20_8`
Automated: true
Test ID: TC-REQ-001-003
Verifies:
- REQ-001.02-AC01
- REQ-001.07-AC01
Test reference: `tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_dividend_precision_and_reversal_round_trip_on_real_postgres`, `tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_dividend_large_integer_with_8dp_is_rejected_to_protect_double_snapshot`
Test reference: `tests/test_v1_portfolios_manual_ledger.py::test_dividend_updates_only_profit_buffer_and_records_executed_at_and_tax`, `tests/test_v1_portfolios_manual_ledger.py::test_dividend_tax_defaults_to_zero_when_omitted`, `tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_dividend_precision_and_reversal_round_trip_on_real_postgres`
Automated: true
Test ID: TC-REQ-001-004
Verifies:
- REQ-001.02-AC01
- REQ-001.08-AC01
Test reference: `tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_existing_high_precision_snapshot_still_allows_legacy_manual_actions`, `tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_large_invest_and_large_dividend_tax_metadata_are_accepted_when_snapshot_is_safe`
- REQ-001.03-AC01
Test reference: `tests/test_v1_portfolios_manual_ledger.py::test_dividend_executed_at_uses_utc_midnight_independent_of_server_timezone`
Automated: true
Test ID: TC-REQ-001-005
Verifies:
- REQ-001.03-AC01
Test reference: `tests/test_v1_portfolios_manual_ledger.py::test_dividend_updates_only_profit_buffer_and_records_executed_at_and_tax`
Automated: true
Test ID: TC-REQ-001-006
Verifies:
- REQ-001.04-AC01
Test reference: `tests/test_v1_portfolios_manual_ledger.py::test_unified_ledger_includes_dividend_fields`, `tests/test_v1_portfolios_manual_ledger.py::test_unified_ledger_openapi_contains_nullable_amount`
Automated: true
Test ID: TC-REQ-001-007
Test ID: TC-REQ-001-006
Verifies:
- REQ-001.05-AC01
Test reference: `tests/test_v1_portfolios_manual_ledger.py::test_dividend_duplicate_requires_confirmation`, `tests/test_v1_portfolios_manual_ledger.py::test_dividend_with_different_decimal_amount_is_not_treated_as_duplicate`
Automated: true
Test ID: TC-REQ-001-007
Verifies:
- REQ-001.05-AC02
Test reference: `tests/test_v1_portfolios_manual_ledger.py::test_dividend_duplicate_requires_confirmation`
Test reference: `tests/test_v1_portfolios_manual_ledger.py::test_dividend_duplicate_requires_confirmation`, `tests/test_v1_portfolios_manual_ledger.py::test_reversed_dividend_is_excluded_from_future_duplicate_candidate_matching`
Automated: true
Test ID: TC-REQ-001-008
@@ -55,24 +51,23 @@ Automated: true
Test ID: TC-REQ-001-009
Verifies:
- REQ-001.06-AC01
- REQ-001.07-AC01
Test reference: `tests/test_v1_portfolios_manual_ledger.py::test_dividend_reversal_decreases_profit_buffer`, `tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_dividend_precision_and_reversal_round_trip_on_real_postgres`
Test reference: `tests/test_v1_portfolios_manual_ledger.py::test_dividend_reversal_decreases_profit_buffer`, `tests/test_v1_portfolios_manual_ledger.py::test_duplicate_reversal_is_rejected_without_state_change`, `tests/test_v1_portfolios_manual_ledger.py::test_dividend_reversal_insufficient_buffer_is_rejected_without_state_change`, `tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_dividend_precision_and_reversal_round_trip_on_real_postgres`
Automated: true
Test ID: TC-REQ-001-010
Verifies:
- REQ-001.07-AC01
Test reference: `tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_large_existing_buffer_plus_min_dividend_is_rejected_before_snapshot_divergence`
Test reference: `tests/test_v1_portfolios_manual_ledger.py::test_dividend_with_different_decimal_amount_is_not_treated_as_duplicate`, `tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_dividend_precision_and_reversal_round_trip_on_real_postgres`, `tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_large_existing_buffer_plus_min_dividend_is_rejected_before_snapshot_divergence`
Automated: true
Test ID: TC-REQ-001-011
Verifies:
- REQ-001.08-AC01
Test reference: `tests/test_v1_portfolios_manual_ledger.py::test_confirm_duplicate_false_is_accepted_for_non_dividend_actions`, `tests/test_v1_portfolios_manual_ledger.py::test_confirm_duplicate_true_is_rejected_for_non_dividend_actions`
Test reference: `tests/test_v1_portfolios_manual_ledger.py::test_confirm_duplicate_false_is_accepted_for_non_dividend_actions`, `tests/test_v1_portfolios_manual_ledger.py::test_confirm_duplicate_true_is_rejected_for_non_dividend_actions`, `tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_existing_high_precision_snapshot_still_allows_legacy_manual_actions`, `tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_large_invest_and_large_dividend_tax_metadata_are_accepted_when_snapshot_is_safe`
Automated: true
Test ID: TC-REQ-001-012
Verifies:
- REQ-001.09-AC01
Test reference: `tests/test_v1_portfolios_manual_ledger.py::test_manual_write_rejects_service_token`, `tests/test_v1_portfolios_manual_ledger.py::test_owner_mismatch_returns_403`, `tests/test_v1_portfolios_manual_ledger.py::test_daemon_managed_requires_confirmation_then_records_evidence`, `tests/test_v1_portfolios_manual_ledger.py::test_manual_ledger_conflict_message_is_sanitized`
Test reference: `tests/test_v1_portfolios_manual_ledger.py::test_manual_write_rejects_service_token`, `tests/test_v1_portfolios_manual_ledger.py::test_owner_mismatch_returns_403`, `tests/test_v1_portfolios_manual_ledger.py::test_daemon_managed_requires_confirmation_then_records_evidence`, `tests/test_v1_portfolios_manual_ledger.py::test_manual_ledger_conflict_message_is_sanitized`, `tests/test_v1_portfolios_manual_ledger.py::test_manual_ledger_conflict_secret_is_not_rendered_in_logs`
Automated: true
+179
View File
@@ -2,11 +2,14 @@
from __future__ import annotations
import os
import time
from contextlib import ExitStack
from datetime import UTC, datetime
from typing import Any
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from app.core.config import Settings, get_settings
@@ -506,6 +509,54 @@ def test_dividend_updates_only_profit_buffer_and_records_executed_at_and_tax() -
assert body["source"] == "manual"
def test_dividend_tax_defaults_to_zero_when_omitted() -> None:
repo = _FakeRepo(_portfolio(cash=1000, quantity=10, price=10, buffer=500))
client = _make_client(repo)
resp = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
json={"action": "DIVIDEND", "amount": 120, "executed_at": "2026-09-01", "note": "Q3"},
headers=_auth(),
)
assert resp.status_code == 201
body = resp.json()
assert body["tax"] == 0.0
assert body["cash_balance_after"] == 1000.0
assert body["asset_quantity_after"] == 10.0
assert body["profit_buffer_after"] == 620.0
def test_dividend_executed_at_uses_utc_midnight_independent_of_server_timezone(monkeypatch: pytest.MonkeyPatch) -> None:
repo = _FakeRepo(_portfolio())
client = _make_client(repo)
original_tz = None
if hasattr(time, "tzset"):
original_tz = os.environ.get("TZ")
monkeypatch.setenv("TZ", "Asia/Seoul")
time.tzset()
try:
resp = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
json={"action": "DIVIDEND", "amount": 1, "executed_at": "2026-09-01"},
headers=_auth(),
)
finally:
if hasattr(time, "tzset"):
if original_tz is None:
monkeypatch.delenv("TZ", raising=False)
else:
monkeypatch.setenv("TZ", original_tz)
time.tzset()
assert resp.status_code == 201
body = resp.json()
assert body["executed_at"] == "2026-09-01T00:00:00Z"
assert body["recorded_at"] != body["executed_at"]
def test_dividend_requires_executed_at_and_rejects_fee_field() -> None:
repo = _FakeRepo(_portfolio())
client = _make_client(repo)
@@ -583,6 +634,51 @@ def test_dividend_duplicate_requires_confirmation() -> None:
assert confirmed.status_code == 201
def test_dividend_with_different_decimal_amount_is_not_treated_as_duplicate() -> None:
repo = _FakeRepo(_portfolio())
client = _make_client(repo)
first = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
json={"action": "DIVIDEND", "amount": "1.00000000", "executed_at": "2026-09-01", "note": "Q3"},
headers=_auth(),
)
assert first.status_code == 201
second = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
json={"action": "DIVIDEND", "amount": "1.00000001", "executed_at": "2026-09-01", "note": "Q3"},
headers=_auth(),
)
assert second.status_code == 201
def test_reversed_dividend_is_excluded_from_future_duplicate_candidate_matching() -> None:
repo = _FakeRepo(_portfolio())
client = _make_client(repo)
first = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
json={"action": "DIVIDEND", "amount": 50, "executed_at": "2026-09-01", "note": "Q3"},
headers=_auth(),
)
assert first.status_code == 201
reversal = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
json={"action": "REVERSAL", "target_ledger_entry_id": first.json()["ledger_entry_id"]},
headers=_auth(),
)
assert reversal.status_code == 201
replay = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
json={"action": "DIVIDEND", "amount": 50, "executed_at": "2026-09-01", "note": "Q3"},
headers=_auth(),
)
assert replay.status_code == 201
def test_confirm_duplicate_false_is_accepted_for_non_dividend_actions() -> None:
repo = _FakeRepo(_portfolio())
client = _make_client(repo)
@@ -635,6 +731,89 @@ def test_dividend_reversal_decreases_profit_buffer() -> None:
assert reversal.json()["profit_buffer_after"] == 200.0
def test_duplicate_reversal_is_rejected_without_state_change() -> None:
repo = _FakeRepo(_portfolio(buffer=200))
client = _make_client(repo)
dividend = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
json={"action": "DIVIDEND", "amount": 80, "executed_at": "2026-09-01", "note": "Q3"},
headers=_auth(),
)
assert dividend.status_code == 201
first_reversal = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
json={"action": "REVERSAL", "target_ledger_entry_id": dividend.json()["ledger_entry_id"]},
headers=_auth(),
)
assert first_reversal.status_code == 201
before_count = len(repo.entries)
second_reversal = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
json={"action": "REVERSAL", "target_ledger_entry_id": dividend.json()["ledger_entry_id"]},
headers=_auth(),
)
assert second_reversal.status_code == 409
assert second_reversal.json()["detail"]["code"] == "LEDGER_ENTRY_ALREADY_REVERSED"
assert len(repo.entries) == before_count
assert repo.portfolio.profit_buffer == 200.0
def test_dividend_reversal_insufficient_buffer_is_rejected_without_state_change() -> None:
repo = _FakeRepo(_portfolio(buffer=0, cash=1000, quantity=10, price=10))
client = _make_client(repo)
dividend = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
json={"action": "DIVIDEND", "amount": 100, "executed_at": "2026-09-01", "note": "Q3"},
headers=_auth(),
)
assert dividend.status_code == 201
withdraw = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
json={"action": "WITHDRAW", "amount": 100},
headers=_auth(),
)
assert withdraw.status_code == 201
assert repo.portfolio.profit_buffer == 0.0
before_count = len(repo.entries)
reversal = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
json={"action": "REVERSAL", "target_ledger_entry_id": dividend.json()["ledger_entry_id"]},
headers=_auth(),
)
assert reversal.status_code == 409
assert reversal.json()["detail"]["code"] == "INSUFFICIENT_BUFFER"
assert len(repo.entries) == before_count
assert repo.portfolio.profit_buffer == 0.0
def test_manual_ledger_conflict_secret_is_not_rendered_in_logs(caplog: pytest.LogCaptureFixture) -> None:
repo = _FakeRepo(_portfolio())
client = _make_client(repo)
secret = "SENTINEL_SECRET_TOKEN_67890"
def _raise_secret(*args: Any, **kwargs: Any) -> LedgerEntry:
raise ValueError(f"synthetic persistence {secret}")
repo.record_manual_ledger = _raise_secret # type: ignore[method-assign]
caplog.clear()
caplog.set_level("INFO")
resp = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
json={"action": "DIVIDEND", "amount": 1, "executed_at": "2026-09-01"},
headers=_auth(),
)
assert resp.status_code == 422
rendered = "\n".join(f"{record.name}:{record.getMessage()}" for record in caplog.records)
assert secret not in rendered
def test_unified_ledger_includes_dividend_fields() -> None:
repo = _FakeRepo(_portfolio())
client = _make_client(repo)