""" 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