From 1015b465bb64a5cfe9271044d43d079152c81e20 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:08:31 +0000 Subject: [PATCH] test(req-001): add missing dividend verification regressions and tighten catalog mappings Co-authored-by: genichin <4282227+genichin@users.noreply.github.com> --- .../req-001_provenance-reapproval-request.md | 25 +++ docs/requirements/req-001_test-catalog.md | 39 ++-- tests/test_v1_portfolios_manual_ledger.py | 179 ++++++++++++++++++ 3 files changed, 221 insertions(+), 22 deletions(-) create mode 100644 docs/requirements/req-001_provenance-reapproval-request.md diff --git a/docs/requirements/req-001_provenance-reapproval-request.md b/docs/requirements/req-001_provenance-reapproval-request.md new file mode 100644 index 0000000..5ce13e0 --- /dev/null +++ b/docs/requirements/req-001_provenance-reapproval-request.md @@ -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를 직접 변경하지 않는다. diff --git a/docs/requirements/req-001_test-catalog.md b/docs/requirements/req-001_test-catalog.md index 4cedcb6..da93cb9 100644 --- a/docs/requirements/req-001_test-catalog.md +++ b/docs/requirements/req-001_test-catalog.md @@ -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 diff --git a/tests/test_v1_portfolios_manual_ledger.py b/tests/test_v1_portfolios_manual_ledger.py index b5a8cc7..df20421 100644 --- a/tests/test_v1_portfolios_manual_ledger.py +++ b/tests/test_v1_portfolios_manual_ledger.py @@ -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)