teecup/tests/test_account_merge.py

273 lines
11 KiB
Python
Raw Permalink Normal View History

"""
Kontosammenslåing (ADR-080) -- dekker konfliktkartet fra planen: org-rolle-
kollisjon, player-sammenslåing med ekte team_roster-kollisjon, venn-
selvlenke, delt-venn-dedup, sekundær-e-post-overføring, token-utløp, og en
full ende-til-ende-sammenslåing som beviser taper-kontoen faktisk er borte.
"""
import pytest
from fastapi import HTTPException
from app.account_merge import compute_merge_preview, execute_merge
from app.auth import resolve_user_id_by_email
from tests.conftest import (
add_membership,
create_friendship,
create_org,
create_player,
create_team,
create_team_roster,
create_tournament,
create_user,
)
import app.db as app_db
async def test_resolve_user_id_by_email_checks_primary_then_secondary(pool):
user_id = await create_user(email="primary@pytest.invalid")
async with app_db.plain_connection() as conn:
resolved = await resolve_user_id_by_email(conn, "primary@pytest.invalid")
assert resolved == user_id
await conn.execute(
"INSERT INTO user_secondary_email (user_id, email) VALUES ($1, $2)", user_id, "secondary@pytest.invalid"
)
resolved = await resolve_user_id_by_email(conn, "secondary@pytest.invalid")
assert resolved == user_id
assert await resolve_user_id_by_email(conn, "nobody@pytest.invalid") is None
async def test_preview_shows_org_membership_conflict_and_resulting_role(pool):
org_id = await create_org()
keeper_id = await create_user()
loser_id = await create_user()
await add_membership(org_id, keeper_id, role="member")
await add_membership(org_id, loser_id, role="owner")
preview = await compute_merge_preview(keeper_id, loser_id)
assert len(preview.organization_memberships) == 1
m = preview.organization_memberships[0]
assert m.keeper_role == "member"
assert m.loser_role == "owner"
assert m.resulting_role == "owner"
async def test_merge_resolves_org_membership_to_highest_role(pool):
org_id = await create_org()
keeper_id = await create_user()
loser_id = await create_user()
await add_membership(org_id, keeper_id, role="member")
await add_membership(org_id, loser_id, role="owner")
await execute_merge(keeper_id, loser_id)
async with app_db.plain_connection() as conn:
rows = await conn.fetch("SELECT user_id::text AS user_id, role FROM organization_membership WHERE organization_id = $1", org_id)
assert len(rows) == 1
assert rows[0]["user_id"] == keeper_id
assert rows[0]["role"] == "owner"
async def test_merge_repoints_membership_when_only_loser_is_member(pool):
org_id = await create_org()
keeper_id = await create_user()
loser_id = await create_user()
await add_membership(org_id, loser_id, role="admin")
await execute_merge(keeper_id, loser_id)
async with app_db.plain_connection() as conn:
rows = await conn.fetch("SELECT user_id::text AS user_id, role FROM organization_membership WHERE organization_id = $1", org_id)
assert len(rows) == 1
assert rows[0]["user_id"] == keeper_id
assert rows[0]["role"] == "admin"
async def test_merge_merges_player_rows_and_dedupes_team_roster_collision(pool):
"""Begge kontoer har hver sin player-rad i SAMME org, begge rostret på
SAMME lag -- ekte UNIQUE(team_id, player_id)-kollisjon. Etter
sammenslåing skal det finnes NØYAKTIG ÉN player-rad og ÉN team_roster-
rad, eid av keeper."""
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
keeper_id = await create_user()
loser_id = await create_user()
keeper_player_id = await create_player(org_id, user_id=keeper_id, display_name="Keeper")
loser_player_id = await create_player(org_id, user_id=loser_id, display_name="Loser")
tournament_id = await create_tournament(org_id)
team_id = await create_team(org_id, tournament_id)
await create_team_roster(org_id, team_id, keeper_player_id)
await create_team_roster(org_id, team_id, loser_player_id)
preview = await compute_merge_preview(keeper_id, loser_id)
org_preview = next(p for p in preview.player_links if p.organization_id == org_id)
assert org_preview.keeper_has_player is True
assert org_preview.loser_has_player is True
assert org_preview.colliding_registrations == 1
await execute_merge(keeper_id, loser_id)
async with app_db.org_connection(org_id) as conn:
player_rows = await conn.fetch("SELECT id::text AS id, user_id::text AS user_id FROM player WHERE organization_id = $1", org_id)
roster_rows = await conn.fetch("SELECT player_id::text AS player_id FROM team_roster WHERE team_id = $1", team_id)
assert len(player_rows) == 1
assert player_rows[0]["user_id"] == keeper_id
assert len(roster_rows) == 1
assert roster_rows[0]["player_id"] == player_rows[0]["id"]
async def test_merge_repoints_player_when_only_loser_has_one(pool):
org_id = await create_org()
keeper_id = await create_user()
loser_id = await create_user()
await create_player(org_id, user_id=loser_id, display_name="Loser")
await execute_merge(keeper_id, loser_id)
async with app_db.org_connection(org_id) as conn:
row = await conn.fetchrow("SELECT user_id::text AS user_id FROM player WHERE organization_id = $1", org_id)
assert row["user_id"] == keeper_id
async def test_merge_deletes_self_loop_friendship(pool):
"""Keeper og taper var venner med HVERANDRE -- må slettes, ikke
re-pekes (ville brutt CHECK(requester <> addressee))."""
keeper_id = await create_user()
loser_id = await create_user()
await create_friendship(keeper_id, loser_id, status="accepted")
await execute_merge(keeper_id, loser_id)
async with app_db.plain_connection() as conn:
count = await conn.fetchval(
"SELECT count(*) FROM friendship WHERE requester_user_id = $1 OR addressee_user_id = $1", keeper_id
)
assert count == 0
async def test_merge_dedupes_shared_friend_prefers_accepted(pool):
"""Keeper og taper er BEGGE venn med samme tredjeperson -- keeperens er
'pending', taperens er 'accepted'. Etter sammenslåing skal keeper ha
NØYAKTIG ÉN vennskapsrad med tredjepersonen, status 'accepted'."""
keeper_id = await create_user()
loser_id = await create_user()
third_id = await create_user()
await create_friendship(keeper_id, third_id, status="pending")
await create_friendship(loser_id, third_id, status="accepted")
await execute_merge(keeper_id, loser_id)
async with app_db.plain_connection() as conn:
rows = await conn.fetch(
"SELECT status FROM friendship WHERE (requester_user_id = $1 AND addressee_user_id = $2) "
"OR (requester_user_id = $2 AND addressee_user_id = $1)",
keeper_id,
third_id,
)
assert len(rows) == 1
assert rows[0]["status"] == "accepted"
async def test_merge_transfers_secondary_email_and_demotes_loser_primary(pool):
keeper_id = await create_user(email="keeper@pytest.invalid")
loser_id = await create_user(email="loser@pytest.invalid")
await execute_merge(keeper_id, loser_id)
async with app_db.plain_connection() as conn:
row = await conn.fetchrow(
"SELECT user_id::text AS user_id FROM user_secondary_email WHERE email = 'loser@pytest.invalid'"
)
assert row is not None
assert row["user_id"] == keeper_id
async def test_full_end_to_end_merge_deletes_loser_account_and_frees_email(pool):
"""Keeper medlem i org A (member), taper medlem i BÅDE org A (owner --
kollisjon, owner skal vinne) og org B (admin -- ren repeking). Pluss
venner. Bekreft alt riktig, OG at taper-kontoen faktisk er borte
(samme bevis-mønster som ADR-032 sin egen e-postbytte-test: ny
innlogging med taperens gamle e-post oppretter en FRISK konto)."""
org_a = await create_org()
org_b = await create_org()
keeper_id = await create_user(email="keeper2@pytest.invalid")
loser_id = await create_user(email="loser2@pytest.invalid")
friend_id = await create_user()
await add_membership(org_a, keeper_id, role="member")
await add_membership(org_a, loser_id, role="owner")
await add_membership(org_b, loser_id, role="admin")
await create_friendship(keeper_id, friend_id, status="accepted")
result = await execute_merge(keeper_id, loser_id)
assert result.keeper_id == keeper_id
assert result.merged_organization_count == 2
async with app_db.plain_connection() as conn:
assert await conn.fetchval("SELECT role FROM organization_membership WHERE organization_id = $1 AND user_id = $2", org_a, keeper_id) == "owner"
assert await conn.fetchval("SELECT role FROM organization_membership WHERE organization_id = $1 AND user_id = $2", org_b, keeper_id) == "admin"
assert await conn.fetchval("SELECT EXISTS(SELECT 1 FROM app_user WHERE id = $1)", loser_id) is False
# Taperens gamle e-post er nå FRI (kun keeperens sekundær-e-post,
# ikke lenger noen konto sin PRIMÆRE) -- en ny innlogging med den
# skal derfor opprette en FRISK, tom konto, ikke gjenopplive taperen.
freed_email_owner = await conn.fetchval("SELECT id FROM app_user WHERE email = 'loser2@pytest.invalid'")
assert freed_email_owner is None
secondary_owner = await conn.fetchval(
"SELECT user_id::text AS user_id FROM user_secondary_email WHERE email = 'loser2@pytest.invalid'"
)
assert secondary_owner == keeper_id
async def test_confirm_rejects_expired_token(pool):
from app.routers.account_merge import AccountMergeConfirm, confirm_account_merge
from app.routers.auth import _hash_secret
keeper_id = await create_user()
loser_id = await create_user()
raw_token = "expired-test-token"
async with app_db.plain_connection() as conn:
await conn.execute(
"INSERT INTO account_merge_token (initiator_user_id, target_user_id, token_hash, expires_at) "
"VALUES ($1, $2, $3, now() - interval '1 hour')",
keeper_id,
loser_id,
_hash_secret(raw_token),
)
with pytest.raises(HTTPException) as exc_info:
await confirm_account_merge(AccountMergeConfirm(token=raw_token))
assert exc_info.value.status_code == 401
# Kontoene består uendret -- avvist token skal aldri utløse sammenslåing.
async with app_db.plain_connection() as conn:
assert await conn.fetchval("SELECT EXISTS(SELECT 1 FROM app_user WHERE id = $1)", loser_id) is True
async def test_confirm_rejects_already_consumed_token(pool):
from app.routers.account_merge import AccountMergeConfirm, confirm_account_merge
from app.routers.auth import _hash_secret
keeper_id = await create_user()
loser_id = await create_user()
raw_token = "already-used-test-token"
async with app_db.plain_connection() as conn:
await conn.execute(
"INSERT INTO account_merge_token (initiator_user_id, target_user_id, token_hash, expires_at, consumed_at) "
"VALUES ($1, $2, $3, now() + interval '1 hour', now())",
keeper_id,
loser_id,
_hash_secret(raw_token),
)
with pytest.raises(HTTPException) as exc_info:
await confirm_account_merge(AccountMergeConfirm(token=raw_token))
assert exc_info.value.status_code == 401