From 7aa3de524af5013341dbb25cf9afc28142010be7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:20:11 +0000 Subject: [PATCH] test(req-001): tighten AC coverage and remove markdown trailing whitespace Co-authored-by: genichin <4282227+genichin@users.noreply.github.com> --- docs/requirements/req-001_test-catalog.md | 83 +++++++++++++------ docs/requirements/req-001_test-evidence.md | 66 +++++++++------ ...tfolio_ledger_manual_metadata_migration.py | 22 +++++ tests/test_v1_portfolios_manual_ledger.py | 65 ++++++++++++++- 4 files changed, 186 insertions(+), 50 deletions(-) diff --git a/docs/requirements/req-001_test-catalog.md b/docs/requirements/req-001_test-catalog.md index da93cb9..fb6b47c 100644 --- a/docs/requirements/req-001_test-catalog.md +++ b/docs/requirements/req-001_test-catalog.md @@ -1,73 +1,108 @@ # REQ-001 Test Catalog -Test ID: TC-REQ-001-001 +Test ID: TC-REQ-001-001 Verifies: - REQ-001.01-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_tax_defaults_to_zero_when_omitted` +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_tax_omitted_persists_zero_on_real_postgres Automated: true -Test ID: TC-REQ-001-002 +Test ID: TC-REQ-001-002 Verifies: - 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` +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 +- tests/test_v1_portfolios_manual_ledger.py::test_dividend_rejects_invalid_amount_tax_and_unrelated_fields_without_state_change Automated: true -Test ID: TC-REQ-001-003 +Test ID: TC-REQ-001-003 Verifies: - 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_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` +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 +Test ID: TC-REQ-001-004 Verifies: - REQ-001.03-AC01 -Test reference: `tests/test_v1_portfolios_manual_ledger.py::test_dividend_executed_at_uses_utc_midnight_independent_of_server_timezone` +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 +Test ID: TC-REQ-001-005 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` +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-006 +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` +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 +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`, `tests/test_v1_portfolios_manual_ledger.py::test_reversed_dividend_is_excluded_from_future_duplicate_candidate_matching` +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 +Test ID: TC-REQ-001-008 Verifies: - REQ-001.05-AC03 -Test reference: `tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_cold_repositories_concurrent_dividend_requests_follow_duplicate_contract` +Test reference: +- tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_cold_repositories_concurrent_dividend_requests_follow_duplicate_contract Automated: true -Test ID: TC-REQ-001-009 +Test ID: TC-REQ-001-009 Verifies: - REQ-001.06-AC01 -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` +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 +Test ID: TC-REQ-001-010 Verifies: - REQ-001.07-AC01 -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` +Test reference: +- tests/test_v1_portfolios_manual_ledger.py::test_dividend_duplicate_check_does_not_collapse_decimals_that_can_alias_in_float +- 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 +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`, `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` +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 +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`, `tests/test_v1_portfolios_manual_ledger.py::test_manual_ledger_conflict_secret_is_not_rendered_in_logs` +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/docs/requirements/req-001_test-evidence.md b/docs/requirements/req-001_test-evidence.md index ce0bcb4..75c8d39 100644 --- a/docs/requirements/req-001_test-evidence.md +++ b/docs/requirements/req-001_test-evidence.md @@ -2,54 +2,72 @@ ## Evidence Batch: local API regression run -Requirement revision: `REQ-001@1.0` -Commit: `1015b465bb64a5cfe9271044d43d079152c81e20` -Environment: `local sandbox / pytest / fake repository` -Executed at: `2026-09-09T12:05:39Z` -Run selector: `pytest -q -o addopts='' tests/test_v1_portfolios_manual_ledger.py` -Run result: `33 passed` +Requirement revision: `REQ-001@1.0` +Commit: `pending-local-commit` +Environment: `local sandbox / pytest / fake repository` +Executed at: `2026-09-09T12:16:43Z` +Run selector: `pytest -q -o addopts='' tests/test_v1_portfolios_manual_ledger.py` +Run result: `35 passed` -Test ID: TC-REQ-001-001 +Test ID: TC-REQ-001-002 Result: PASS -Test ID: TC-REQ-001-002 +Test ID: TC-REQ-001-003 Result: PASS -Test ID: TC-REQ-001-004 +Test ID: TC-REQ-001-004 Result: PASS -Test ID: TC-REQ-001-005 +Test ID: TC-REQ-001-005 Result: PASS -Test ID: TC-REQ-001-006 +Test ID: TC-REQ-001-006 Result: PASS -Test ID: TC-REQ-001-007 +Test ID: TC-REQ-001-007 Result: PASS -Test ID: TC-REQ-001-012 +Test ID: TC-REQ-001-009 +Result: PASS + +Test ID: TC-REQ-001-010 +Result: PASS + +Test ID: TC-REQ-001-012 Result: PASS ## Evidence Batch: independent real PostgreSQL re-review run -Requirement revision: `REQ-001@1.0` -Commit: `f193aa6da33428783b91c1144fc8b9de5b3fb4bb` -Environment: `disposable PostgreSQL / independent re-review` -Executed at: `2026-09-09T12:05:39Z` -Run selector: `tests/test_bat025_portfolio_ledger_manual_metadata_migration.py (module)` -Run result: `7 passed, 2 failed` (`2 failed`는 기존 stale migration-head baseline) +Requirement revision: `REQ-001@1.0` +Commit: `f193aa6da33428783b91c1144fc8b9de5b3fb4bb` +Environment: `disposable PostgreSQL / independent re-review` +Executed at: `2026-09-09T12:05:39Z` +Run selector: `tests/test_bat025_portfolio_ledger_manual_metadata_migration.py (module)` +Run result: `7 passed, 2 failed` (`2 failed` are pre-existing stale migration-head baseline) -Test ID: TC-REQ-001-003 +Test ID: TC-REQ-001-003 Result: PASS -Test ID: TC-REQ-001-008 +Test ID: TC-REQ-001-008 Result: PASS -Test ID: TC-REQ-001-009 +Test ID: TC-REQ-001-009 Result: PASS -Test ID: TC-REQ-001-010 +Test ID: TC-REQ-001-010 Result: PASS -Test ID: TC-REQ-001-011 +Test ID: TC-REQ-001-011 Result: PASS + +## Evidence Batch: local real PostgreSQL availability + +Requirement revision: `REQ-001@1.0` +Commit: `pending-local-commit` +Environment: `local sandbox / PostgreSQL DSN not configured` +Executed at: `2026-09-09T12:16:43Z` +Run selector: `pytest -q -o addopts='' tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_dividend_tax_omitted_persists_zero_on_real_postgres` +Run result: `1 skipped` + +Test ID: TC-REQ-001-001 +Result: BLOCKED diff --git a/tests/test_bat025_portfolio_ledger_manual_metadata_migration.py b/tests/test_bat025_portfolio_ledger_manual_metadata_migration.py index 0da321c..b589574 100644 --- a/tests/test_bat025_portfolio_ledger_manual_metadata_migration.py +++ b/tests/test_bat025_portfolio_ledger_manual_metadata_migration.py @@ -313,6 +313,28 @@ def test_dividend_precision_and_reversal_round_trip_on_real_postgres( repo.close() +def test_dividend_tax_omitted_persists_zero_on_real_postgres( + isolated_settings: Settings, +) -> None: + """Omitted tax should persist as zero in PostgreSQL.""" + upgrade_to_head(isolated_settings) + repo = _repo(isolated_settings) + portfolio_id = _seed_portfolio(repo, "dividend-tax-omitted") + + dividend = repo.record_manual_ledger( + portfolio_id, + action="DIVIDEND", + amount=Decimal("1.00000000"), + executed_at=datetime(2026, 9, 1, 0, 0, 0, tzinfo=UTC), + note="tax-omitted", + ) + assert dividend.tax_amount == 0.0 + with repo._connect().cursor() as cur: + cur.execute("SELECT tax_amount::text FROM portfolio_ledger WHERE ledger_entry_id = %s", (dividend.ledger_entry_id,)) + assert cur.fetchone()[0] == "0.00000000" + repo.close() + + def test_existing_high_precision_snapshot_still_allows_legacy_manual_actions( isolated_settings: Settings, ) -> None: diff --git a/tests/test_v1_portfolios_manual_ledger.py b/tests/test_v1_portfolios_manual_ledger.py index df20421..dc302d0 100644 --- a/tests/test_v1_portfolios_manual_ledger.py +++ b/tests/test_v1_portfolios_manual_ledger.py @@ -6,6 +6,7 @@ import os import time from contextlib import ExitStack from datetime import UTC, datetime +from decimal import Decimal from typing import Any from unittest.mock import patch @@ -71,6 +72,7 @@ class _FakeRepo: self.principal = principal or _token_principal() self.entries: list[LedgerEntry] = [] self._next_id = 1 + self._dividend_amounts: dict[int, Decimal] = {} def close(self) -> None: pass @@ -86,7 +88,9 @@ class _FakeRepo: def record_manual_ledger(self, portfolio_id: str, **kwargs: Any) -> LedgerEntry: self.get(portfolio_id) action = kwargs["action"] - amount = float(kwargs.get("amount") or 0.0) + amount_raw = kwargs.get("amount") + amount_decimal = Decimal(str(amount_raw if amount_raw is not None else 0)) + amount = float(amount_decimal) quantity = float(kwargs.get("quantity") or 0.0) price = float(kwargs.get("price") or 0.0) fee = float(kwargs.get("fee") or 0.0) @@ -141,7 +145,7 @@ class _FakeRepo: if e.action == "DIVIDEND" and e.ledger_entry_id not in reversed_ids and e.executed_at == executed_at - and abs(float(e.price) - amount) < 1e-9 + and self._dividend_amounts.get(e.ledger_entry_id, Decimal(str(e.price))) == amount_decimal and e.note == note ), None, @@ -211,6 +215,8 @@ class _FakeRepo: ) self._next_id += 1 self.entries.append(entry) + if action == "DIVIDEND": + self._dividend_amounts[entry.ledger_entry_id] = amount_decimal return entry def list_manual_ledger_entries(self, portfolio_id: str, limit: int = 50) -> list[LedgerEntry]: @@ -576,6 +582,42 @@ def test_dividend_requires_executed_at_and_rejects_fee_field() -> None: assert invalid_fee.status_code == 422 +def test_dividend_rejects_invalid_amount_tax_and_unrelated_fields_without_state_change() -> None: + repo = _FakeRepo(_portfolio(cash=1000, quantity=10, price=10, buffer=500)) + client = _make_client(repo) + baseline = ( + repo.portfolio.cash_balance, + repo.portfolio.asset_quantity, + repo.portfolio.profit_buffer, + len(repo.entries), + ) + + invalid_payloads = [ + {"action": "DIVIDEND", "amount": 0, "executed_at": "2026-09-01"}, + {"action": "DIVIDEND", "amount": -1, "executed_at": "2026-09-01"}, + {"action": "DIVIDEND", "amount": 1, "tax": -1, "executed_at": "2026-09-01"}, + {"action": "DIVIDEND", "amount": 1, "tax": "0.000000001", "executed_at": "2026-09-01"}, + {"action": "DIVIDEND", "amount": 1, "tax": "1000000000000.00000000", "executed_at": "2026-09-01"}, + {"action": "DIVIDEND", "amount": 1, "executed_at": "2026-09-01", "quantity": 1}, + {"action": "DIVIDEND", "amount": 1, "executed_at": "2026-09-01", "price": 1}, + {"action": "DIVIDEND", "amount": 1, "executed_at": "2026-09-01", "target_ledger_entry_id": 1}, + ] + + for payload in invalid_payloads: + resp = client.post( + f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", + json=payload, + headers=_auth(), + ) + assert resp.status_code == 422 + assert ( + repo.portfolio.cash_balance, + repo.portfolio.asset_quantity, + repo.portfolio.profit_buffer, + len(repo.entries), + ) == baseline + + def test_dividend_rejects_sub_minimum_numeric_precision() -> None: repo = _FakeRepo(_portfolio()) client = _make_client(repo) @@ -653,6 +695,25 @@ def test_dividend_with_different_decimal_amount_is_not_treated_as_duplicate() -> assert second.status_code == 201 +def test_dividend_duplicate_check_does_not_collapse_decimals_that_can_alias_in_float() -> None: + repo = _FakeRepo(_portfolio()) + client = _make_client(repo) + + first = client.post( + f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", + json={"action": "DIVIDEND", "amount": "90000000.00000001", "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": "90000000.00000002", "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)