Selvbetjent sammenslåing av to TeeCup-kontoer: keeper (initiativtaker) ber om sammenslåing, bekreftelseslenke sendt til taperens e-post beviser eierskap, taperens data (org-medlemskap/spillerkoblinger/runder/venner) flyttes over og taperens konto slettes. Ny migrasjon 077 (account_merge_ token, ikke kjørt mot ekte teecup_db ennå), ny app/account_merge.py (N+1-transaksjoner per RLS-grensen, fullt konfliktkart), nye endepunkter i app/routers/account_merge.py, ny frontend-seksjon i kontoinnstillinger + egen bekreftelsesside. Fant og fikset en reell RLS-relatert bug via testsuiten før produksjon: seks RLS-beskyttede tabeller var feilaktig plassert i den globale "trygt å re-peke"-løkken, forårsaket et krasj pga. en Postgres GUC-kvirk på pooled forbindelser. Se ADR-080 for full begrunnelse. 12 nye tester (95/95 backend totalt), full scratch-verifisert ende-til-ende inkl. lys/mørk, ekte teecup_db urørt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
272 lines
11 KiB
Python
272 lines
11 KiB
Python
"""
|
|
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
|