Ctrl K
ComunidadGuías…

tests/test_portfolio_delete.py

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

Archivo original ↗Preguntar a KiBot
"""Removing the last member, and the deletion it offers instead.

A portfolio is reachable only through membership — `load_auth` builds the list
from `portfolio_member`, and there is no admin override. So removing the only
member would leave a portfolio holding every trade and visible to nobody.

The rules that matter are about what cannot happen by accident.
"""

from __future__ import annotations

import pytest
from sqlalchemy import select

import factories as fac
from app import tenancy
from app.models import Portfolio, PortfolioMember, Trade, User
from test_routes import make_login, session_csrf

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


def _members(client, session_factory):
    with session_factory() as db:
        rows = db.scalars(select(PortfolioMember)).all()
        return {r.id: r.role for r in rows}


@pytest.fixture
def owner(client, session_factory):
    make_login(client, session_factory)
    return client


# --------------------------------------------------------------------------- #
# What must not happen by accident
# --------------------------------------------------------------------------- #

def test_the_only_member_is_not_removed_without_saying_delete(owner, session_factory):
    """The portfolio would still exist, hold every trade, and be reachable by
    nobody — membership is the only route to one."""
    with session_factory() as db:
        member_id = db.scalar(select(PortfolioMember)).id

    resp = owner.post(f"/members/{member_id}/remove", follow_redirects=False,
                      data={"_csrf": session_csrf(session_factory)})

    assert "needs+an+owner" in resp.headers["location"]
    with session_factory() as db:
        assert db.scalar(select(PortfolioMember)) is not None
        assert db.scalar(select(Portfolio)) is not None


def test_the_last_owner_is_not_removed_while_others_remain(owner, session_factory):
    """Different case, same refusal: the portfolio would be left with members
    and nobody able to administer it. No deletion is offered — other people
    are still in it."""
    with session_factory() as db:
        second = fac.make_user(db, "second@example.test")
        portfolio = db.scalar(select(Portfolio))
        db.add(PortfolioMember(portfolio_id=portfolio.id, user_id=second.id,
                               role="member"))
        db.commit()
        owner_row = db.scalar(select(PortfolioMember).where(
            PortfolioMember.role == "owner"))
        owner_id = owner_row.id

    resp = owner.post(f"/members/{owner_id}/remove", follow_redirects=False,
                      data={"_csrf": session_csrf(session_factory),
                            "delete_portfolio": "1"})

    # Even ticked: the offer is for an EMPTY portfolio, not a populated one.
    assert "needs+an+owner" in resp.headers["location"]
    with session_factory() as db:
        assert db.scalar(select(Portfolio)) is not None


def test_a_member_who_is_not_the_last_is_removed_normally(owner, session_factory):
    with session_factory() as db:
        second = fac.make_user(db, "second@example.test")
        portfolio = db.scalar(select(Portfolio))
        db.add(PortfolioMember(portfolio_id=portfolio.id, user_id=second.id,
                               role="member"))
        db.commit()
        their_id = db.scalar(select(PortfolioMember).where(
            PortfolioMember.role == "member")).id

    owner.post(f"/members/{their_id}/remove", follow_redirects=False,
               data={"_csrf": session_csrf(session_factory)})

    assert len(_members(owner, session_factory)) == 1


# --------------------------------------------------------------------------- #
# The deletion it offers instead
# --------------------------------------------------------------------------- #

def test_saying_delete_removes_the_portfolio_and_everything_in_it(
    owner, session_factory
):
    import datetime as dt

    with session_factory() as db:
        portfolio = db.scalar(select(Portfolio))
        user = db.scalar(select(User))
        alpha = fac.make_instrument(db, "ALPHA")
        db.flush()
        fac.add_trade(db, alpha, dt.date(2026, 1, 5), "buy", 100, "10.00",
                      portfolio_id=portfolio.id, user_id=user.id)
        db.commit()
        member_id = db.scalar(select(PortfolioMember)).id
    with tenancy.unscoped_session(session_factory) as session:
        assert session.scalar(select(Trade)) is not None

    owner.post(f"/members/{member_id}/remove", follow_redirects=False,
               data={"_csrf": session_csrf(session_factory),
                     "delete_portfolio": "1"})

    with session_factory() as db:
        assert db.scalar(select(Portfolio)) is None
        assert db.scalar(select(PortfolioMember)) is None
    # Trades are portfolio-scoped, so reading them needs a session that says
    # so. The cascade is the whole reason this is one action: a portfolio row
    # left behind with its trades would be unreachable by anyone.
    with tenancy.unscoped_session(session_factory) as session:
        assert session.scalar(select(Trade)) is None


def test_the_account_survives_the_portfolio(owner, session_factory):
    """Deleting a portfolio is not deleting a person. They keep their account,
    their password and any other portfolio they are in."""
    with session_factory() as db:
        member_id = db.scalar(select(PortfolioMember)).id

    owner.post(f"/members/{member_id}/remove", follow_redirects=False,
               data={"_csrf": session_csrf(session_factory),
                     "delete_portfolio": "1"})

    with session_factory() as db:
        assert db.scalar(select(User)) is not None


def test_they_are_not_left_pointing_at_a_portfolio_that_is_gone(
    owner, session_factory
):
    """Deleting the portfolio you are looking at must land you somewhere real.

    Nothing in the delete does that: `load_auth` falls back when the session's
    active portfolio is not one of your memberships — the same path as being
    removed from one. This is here because that is load-bearing and lives a
    long way from the button.
    """
    with session_factory() as db:
        keep = fac.make_portfolio(db, "Still here", owner=db.scalar(select(User)))
        db.commit()
        keep_id = keep.id
        gone = db.scalar(select(PortfolioMember).where(
            PortfolioMember.portfolio_id != keep_id))

    owner.post(f"/members/{gone.id}/remove", follow_redirects=False,
               data={"_csrf": session_csrf(session_factory),
                     "delete_portfolio": "1"})

    assert owner.get("/", headers=HTML, follow_redirects=False).status_code == 200


def test_only_an_owner_can_delete(client, session_factory):
    make_login(client, session_factory, email="member@example.test", admin=False)
    with session_factory() as db:
        row = db.scalar(select(PortfolioMember))
        row.role = "member"
        db.commit()
        member_id = row.id

    resp = client.post(f"/members/{member_id}/remove", follow_redirects=False,
                       data={"_csrf": session_csrf(session_factory),
                             "delete_portfolio": "1"})

    assert resp.status_code == 403
    with session_factory() as db:
        assert db.scalar(select(Portfolio)) is not None


# --------------------------------------------------------------------------- #
# What the page offers
# --------------------------------------------------------------------------- #

def test_the_offer_appears_only_for_the_last_member(owner, session_factory):
    """A tickbox that deletes a portfolio must not be on the row of somebody
    who simply has colleagues."""
    page = owner.get("/members", headers=HTML).text
    assert 'data-last="1"' in page

    with session_factory() as db:
        second = fac.make_user(db, "second@example.test")
        db.add(PortfolioMember(portfolio_id=db.scalar(select(Portfolio)).id,
                               user_id=second.id, role="member"))
        db.commit()

    assert 'data-last="1"' not in owner.get("/members", headers=HTML).text