Ctrl K
ComunidadGuías…

tests/test_holdings_page.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Removing a holding, and what stops it.

The requirement: you must be able to remove a holding added by
accident, it must not have any trades to be removed … if I hover over the
delete button it should tell me why."

Two things make this more than a delete button.

  * **Instruments are shared.** The list, and the price history with it, belongs
    to everyone using the tracker. So removal *deactivates* rather than deletes,
    and the "is anything recorded against it" question has to be asked of every
    portfolio — not just the one asking.
  * **But the answer must not leak.** The panel lists the asking portfolio's own
    trades, with links; anything belonging to somebody else is counted and never
    shown.
"""

from __future__ import annotations

import re

from sqlalchemy import select

import factories as fac
from app.models import Instrument
from test_routes import bind_to_only_portfolio, make_login, session_csrf

HTML = {"accept": "text/html"}


def _page(client) -> str:
    response = client.get("/holdings", headers=HTML)
    assert response.status_code == 200
    return response.text


def test_an_untraded_instrument_offers_a_remove_button(client, session_factory):
    make_login(client, session_factory)
    with session_factory() as s:
        fac.hold(s, fac.make_instrument(s, "SPARE"))
        s.commit()

    page = _page(client)

    assert re.search(r'action="/holdings/\d+/remove"', page), "no Remove control"


def test_removing_one_asks_first_and_says_what_goes(client, session_factory):
    """It went at a click, its note and reinvest setting with it."""
    make_login(client, session_factory)
    with session_factory() as s:
        fac.hold(s, fac.make_instrument(s, "SPARE"))
        s.commit()

    page = _page(client)

    assert re.search(r'<form[^>]*action="/holdings/\d+/remove"[^>]*data-confirm="Remove SPARE '
                     r'from this portfolio\? Its note and reinvest setting go with it\."', page)


def test_removing_it_stops_it_being_tracked(client, session_factory):
    make_login(client, session_factory)
    with session_factory() as s:
        inst = fac.make_instrument(s, "SPARE")
        fac.hold(s, inst)
        s.commit()
        instrument_id = inst.id

    client.post(f"/holdings/{instrument_id}/remove",
                data={"_csrf": session_csrf(session_factory)},
                headers=HTML, follow_redirects=True)

    with session_factory() as s:
        assert s.get(Instrument, instrument_id).active is False


def test_it_is_deactivated_rather_than_deleted(client, session_factory):
    """The row and its price history are shared. Deleting would take that
    history from anybody who later re-adds the same ticker."""
    make_login(client, session_factory)
    with session_factory() as s:
        inst = fac.make_instrument(s, "SPARE")
        fac.hold(s, inst)
        fac.add_price(s, inst, "2026-01-05", "10.00")
        s.commit()
        instrument_id = inst.id

    client.post(f"/holdings/{instrument_id}/remove",
                data={"_csrf": session_csrf(session_factory)},
                headers=HTML, follow_redirects=True)

    with session_factory() as s:
        assert s.get(Instrument, instrument_id) is not None, "the row was deleted"
        from app.models import Price
        assert s.scalars(select(Price).where(
            Price.instrument_id == instrument_id)).all(), "price history was lost"


def test_an_instrument_with_trades_shows_what_is_blocking_it(client, session_factory):
    """Not a disabled button — one that opens and says why, with a link to each
    thing in the way. A `disabled` button cannot be hovered or focused in most
    browsers, so its explanation is unreachable exactly when it is wanted."""
    make_login(client, session_factory)
    with session_factory() as s:
        bind_to_only_portfolio(s)
        inst = fac.make_instrument(s, "HELD")
        fac.hold(s, inst)
        fac.add_trade(s, inst, "2026-01-05", "buy", 10, "5.00")
        s.commit()

    page = _page(client)
    row = page[page.index("HELD"):]

    assert "blockedby" in row, "no explanation panel"
    assert re.search(r'/trade/\d+/edit\?return=/holdings', row), (
        "the panel does not link to the trade in the way")


def test_a_reinvested_distribution_in_the_way_links_to_where_it_is_edited(
        client, session_factory):
    """Its units are a trade, but one edited through its distribution so the
    two stay in step (decisions.md #91). The link went to the trade's own
    edit page, which refuses it, so the one thing in the way could not be
    reached from the panel."""
    make_login(client, session_factory)
    with session_factory() as s:
        bind_to_only_portfolio(s)
        inst = fac.make_instrument(s, "HELD")
        fac.hold(s, inst)
        bought = fac.add_trade(s, inst, "2026-01-05", "buy", 10, "5.00")
        reinvested, dividend = fac.add_drp(s, inst, "2026-02-05", "5.00", 1, "5.00")
        s.commit()
        ids = bought.id, reinvested.id, dividend.id

    page = _page(client)
    links = re.findall(r'href="(/(?:trade|dividend)/\d+/edit\?return=/holdings)"', page)

    assert links == [f"/dividend/{ids[2]}/edit?return=/holdings",
                     f"/trade/{ids[0]}/edit?return=/holdings"]
    assert client.get(links[0], headers=HTML).status_code == 200


def test_the_blocked_row_offers_no_remove_form(client, session_factory):
    make_login(client, session_factory)
    with session_factory() as s:
        bind_to_only_portfolio(s)
        inst = fac.make_instrument(s, "HELD")
        fac.hold(s, inst)
        fac.add_trade(s, inst, "2026-01-05", "buy", 10, "5.00")
        s.commit()
        instrument_id = inst.id

    page = _page(client)

    assert f'action="/holdings/{instrument_id}/remove"' not in page


def test_the_route_refuses_it_too_not_just_the_page(client, session_factory):
    """The button being absent is presentation. A stale tab, or anybody
    posting directly, must hit the same wall."""
    make_login(client, session_factory)
    with session_factory() as s:
        bind_to_only_portfolio(s)
        inst = fac.make_instrument(s, "HELD")
        fac.hold(s, inst)
        fac.add_trade(s, inst, "2026-01-05", "buy", 10, "5.00")
        s.commit()
        instrument_id = inst.id

    resp = client.post(f"/holdings/{instrument_id}/remove",
                       data={"_csrf": session_csrf(session_factory)}, headers=HTML)

    assert resp.status_code == 409
    with session_factory() as s:
        assert s.get(Instrument, instrument_id).active is True


def test_a_dividend_blocks_it_as_well_as_a_trade(client, session_factory):
    """A dividend refers to the instrument too, and leaving one pointing at
    something no longer tracked is the same broken ledger."""
    make_login(client, session_factory)
    with session_factory() as s:
        bind_to_only_portfolio(s)
        inst = fac.make_instrument(s, "PAYER")
        fac.hold(s, inst)
        fac.add_dividend(s, inst, "2026-02-01", "12.34")
        s.commit()
        instrument_id = inst.id

    resp = client.post(f"/holdings/{instrument_id}/remove",
                       data={"_csrf": session_csrf(session_factory)}, headers=HTML)

    assert resp.status_code == 409


def test_the_page_shows_a_price_even_for_something_no_longer_held(client,
                                                                  session_factory):
    """The reason: a position you exited is still one you may be watching,
    and the dashboard deliberately hides it."""
    make_login(client, session_factory)
    with session_factory() as s:
        inst = fac.make_instrument(s, "GONE")
        fac.hold(s, inst)
        fac.add_price(s, inst, "2026-01-05", "41.00")
        fac.add_price(s, inst, "2026-02-05", "42.50")
        s.commit()

    page = _page(client)

    assert "42.50" in page, "the latest close is not shown"
    assert "41.00" not in page, "an older close is being shown instead of the latest"