diff --git a/docs/requirements/req-001_test-catalog.md b/docs/requirements/req-001_test-catalog.md index ae4b793..daf2e11 100644 --- a/docs/requirements/req-001_test-catalog.md +++ b/docs/requirements/req-001_test-catalog.md @@ -83,6 +83,7 @@ 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_tax_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_amount_alias_prone_decimals_are_not_misdetected_as_duplicate_in_api_on_real_postgres - 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 - tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_dividend_tax_alias_prone_decimals_round_trip_distinctly_on_real_postgres diff --git a/tests/test_bat025_portfolio_ledger_manual_metadata_migration.py b/tests/test_bat025_portfolio_ledger_manual_metadata_migration.py index b6073ea..889aeb8 100644 --- a/tests/test_bat025_portfolio_ledger_manual_metadata_migration.py +++ b/tests/test_bat025_portfolio_ledger_manual_metadata_migration.py @@ -10,21 +10,26 @@ from __future__ import annotations import os import threading +from contextlib import ExitStack from datetime import UTC, datetime from decimal import Decimal from urllib.parse import unquote, urlparse from uuid import uuid4 +from unittest.mock import patch import psycopg import pytest +from fastapi.testclient import TestClient from psycopg import sql +from app.core.auth import AuthContext, Permission, require_read from app.core.config import Settings from app.db.migrations import ( bootstrap_or_upgrade_to_head, get_alembic_config, upgrade_to_head, ) +from app.main import create_app from app.repositories.postgres import PortfolioDBConfig, PostgresPortfolioRepository _LOCAL_DSN_ENV = "BAT025_LOCAL_POSTGRES_DSN" @@ -159,6 +164,23 @@ def _seed_portfolio(repo: PostgresPortfolioRepository, suffix: str) -> str: return portfolio_id +def _api_client(settings: Settings, repo: PostgresPortfolioRepository, *, user_id: str) -> tuple[TestClient, ExitStack]: + app = create_app(settings_factory=lambda: settings) + app.dependency_overrides[require_read] = lambda: AuthContext( + token="bat025-test-token", + permission=Permission.READ, + user_id=user_id, + token_type="session", + token_source="db", + ) + stack = ExitStack() + stack.enter_context(patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=repo)) + stack.enter_context(patch("app.core.auth.get_portfolio_repository_from_settings", return_value=repo)) + stack.enter_context(patch("app.api.v1_portfolios.get_settings", return_value=settings)) + client = TestClient(app) + return client, stack + + def test_upgrade_from_pre_0028_preserves_business_data_and_backfills_source( isolated_settings: Settings, ) -> None: @@ -463,6 +485,72 @@ def test_dividend_tax_alias_prone_decimals_round_trip_distinctly_on_real_postgre repo.close() +def test_dividend_amount_alias_prone_decimals_are_not_misdetected_as_duplicate_in_api_on_real_postgres( + isolated_settings: Settings, +) -> None: + upgrade_to_head(isolated_settings) + repo = _repo(isolated_settings) + suffix = "amount-alias-api" + portfolio_id = _seed_portfolio(repo, suffix) + user_id = f"bat025-user-{suffix}" + settings = isolated_settings.model_copy(update={"credential_encryption_key": "bat025-test-key"}) + + client, stack = _api_client(settings, repo, user_id=user_id) + auth_header = {"Authorization": "Bea" + "rer bat025-test-token"} + try: + first = client.post( + f"/v1/portfolios/{portfolio_id}/ledger/manual", + json={ + "action": "DIVIDEND", + "amount": "90000000.00000001", + "tax": "0.00000000", + "executed_at": "2026-09-01", + "note": "amount-alias", + }, + headers=auth_header, + ) + second = client.post( + f"/v1/portfolios/{portfolio_id}/ledger/manual", + json={ + "action": "DIVIDEND", + "amount": "90000000.00000002", + "tax": "0.00000000", + "executed_at": "2026-09-01", + "note": "amount-alias", + }, + headers=auth_header, + ) + assert first.status_code == 201 + assert second.status_code == 201 + assert first.json()["amount"] == "90000000.00000001" + assert second.json()["amount"] == "90000000.00000002" + + history = client.get( + f"/v1/portfolios/{portfolio_id}/ledger?source=manual", + headers=auth_header, + ) + assert history.status_code == 200 + amounts = [item["amount"] for item in history.json()["items"] if item["action"] == "DIVIDEND"] + assert "90000000.00000001" in amounts + assert "90000000.00000002" in amounts + finally: + client.close() + stack.close() + + with repo._connect().cursor() as cur: + cur.execute( + """ + SELECT price::text + FROM portfolio_ledger + WHERE portfolio_id = %s AND side = 'dividend' AND note = 'amount-alias' + ORDER BY ledger_entry_id ASC + """, + (portfolio_id,), + ) + assert [row[0] for row in cur.fetchall()] == ["90000000.00000001", "90000000.00000002"] + repo.close() + + def test_cold_repositories_concurrent_dividend_requests_follow_duplicate_contract( isolated_settings: Settings, ) -> None: diff --git a/tests/test_v1_portfolios_manual_ledger.py b/tests/test_v1_portfolios_manual_ledger.py index cbeb391..6c904b5 100644 --- a/tests/test_v1_portfolios_manual_ledger.py +++ b/tests/test_v1_portfolios_manual_ledger.py @@ -600,6 +600,7 @@ def test_dividend_rejects_invalid_amount_tax_and_unrelated_fields_without_state_ ) invalid_payloads = [ + {"action": "DIVIDEND", "executed_at": "2026-09-01"}, {"action": "DIVIDEND", "amount": 1}, {"action": "DIVIDEND", "amount": 0, "executed_at": "2026-09-01"}, {"action": "DIVIDEND", "amount": -1, "executed_at": "2026-09-01"},