"""Tests for invite functionality.""" import pytest from sqlalchemy import select from invite_utils import ( BIP39_WORDS, generate_invite_identifier, is_valid_identifier_format, normalize_identifier, ) from models import ROLE_REGULAR, Invite, InviteStatus, User from tests.conftest import create_user_with_roles from tests.helpers import unique_email # ============================================================================ # Invite Utils Tests # ============================================================================ def test_bip39_words_loaded(): """BIP39 word list should have exactly 2048 words.""" assert len(BIP39_WORDS) == 2048 def test_generate_invite_identifier_format(): """Generated identifier should have word-word-NN format.""" identifier = generate_invite_identifier() assert is_valid_identifier_format(identifier) parts = identifier.split("-") assert len(parts) == 3 assert parts[0] in BIP39_WORDS assert parts[1] in BIP39_WORDS assert len(parts[2]) == 2 assert parts[2].isdigit() def test_generate_invite_identifier_lowercase(): """Generated identifier should be lowercase.""" for _ in range(10): identifier = generate_invite_identifier() assert identifier == identifier.lower() def test_generate_invite_identifier_randomness(): """Generated identifiers should be different (with high probability).""" identifiers = {generate_invite_identifier() for _ in range(100)} # With ~419M possibilities, 100 samples should all be unique assert len(identifiers) == 100 def test_normalize_identifier_lowercase(): """normalize_identifier should convert to lowercase.""" assert normalize_identifier("APPLE-BANANA-42") == "apple-banana-42" assert normalize_identifier("Apple-Banana-42") == "apple-banana-42" def test_normalize_identifier_strips_whitespace(): """normalize_identifier should strip whitespace.""" assert normalize_identifier(" apple-banana-42 ") == "apple-banana-42" assert normalize_identifier("\tapple-banana-42\n") == "apple-banana-42" def test_is_valid_identifier_format_valid(): """Valid formats should pass.""" assert is_valid_identifier_format("apple-banana-42") is True assert is_valid_identifier_format("zoo-abandon-00") is True assert is_valid_identifier_format("word-another-99") is True def test_is_valid_identifier_format_invalid(): """Invalid formats should fail.""" # Wrong number of parts assert is_valid_identifier_format("apple-banana") is False assert is_valid_identifier_format("apple-banana-42-extra") is False assert is_valid_identifier_format("applebanan42") is False # Empty parts assert is_valid_identifier_format("-banana-42") is False assert is_valid_identifier_format("apple--42") is False # Invalid number format assert is_valid_identifier_format("apple-banana-4") is False # Single digit assert is_valid_identifier_format("apple-banana-420") is False # Three digits assert is_valid_identifier_format("apple-banana-ab") is False # Not digits # ============================================================================ # Invite Model Tests # ============================================================================ @pytest.mark.asyncio async def test_create_invite(client_factory): """Can create an invite with godfather.""" async with client_factory.get_db_session() as db: # Create godfather user godfather = await create_user_with_roles( db, unique_email("godfather"), "password123", [ROLE_REGULAR] ) # Create invite invite = Invite( identifier="test-invite-01", godfather_id=godfather.id, status=InviteStatus.READY, ) db.add(invite) await db.commit() await db.refresh(invite) assert invite.id is not None assert invite.identifier == "test-invite-01" assert invite.godfather_id == godfather.id assert invite.status == InviteStatus.READY assert invite.used_by_id is None assert invite.created_at is not None assert invite.spent_at is None assert invite.revoked_at is None @pytest.mark.asyncio async def test_invite_godfather_relationship(client_factory): """Invite should have godfather relationship loaded.""" async with client_factory.get_db_session() as db: godfather = await create_user_with_roles( db, unique_email("godfather"), "password123", [ROLE_REGULAR] ) invite = Invite( identifier="rel-test-01", godfather_id=godfather.id, ) db.add(invite) await db.commit() # Query invite fresh result = await db.execute( select(Invite).where(Invite.identifier == "rel-test-01") ) loaded_invite = result.scalar_one() assert loaded_invite.godfather is not None assert loaded_invite.godfather.email == godfather.email @pytest.mark.asyncio async def test_invite_unique_identifier(client_factory): """Invite identifier must be unique.""" from sqlalchemy.exc import IntegrityError async with client_factory.get_db_session() as db: godfather = await create_user_with_roles( db, unique_email("godfather"), "password123", [ROLE_REGULAR] ) invite1 = Invite( identifier="unique-test-01", godfather_id=godfather.id, ) db.add(invite1) await db.commit() invite2 = Invite( identifier="unique-test-01", # Same identifier godfather_id=godfather.id, ) db.add(invite2) with pytest.raises(IntegrityError): await db.commit() @pytest.mark.asyncio async def test_invite_status_transitions(client_factory): """Invite status can be changed.""" from datetime import UTC, datetime async with client_factory.get_db_session() as db: godfather = await create_user_with_roles( db, unique_email("godfather"), "password123", [ROLE_REGULAR] ) user = await create_user_with_roles( db, unique_email("invitee"), "password123", [ROLE_REGULAR] ) invite = Invite( identifier="status-test-01", godfather_id=godfather.id, status=InviteStatus.READY, ) db.add(invite) await db.commit() # Transition to SPENT invite.status = InviteStatus.SPENT invite.used_by_id = user.id invite.spent_at = datetime.now(UTC) await db.commit() await db.refresh(invite) assert invite.status == InviteStatus.SPENT assert invite.used_by_id == user.id assert invite.spent_at is not None @pytest.mark.asyncio async def test_invite_revoke(client_factory): """Invite can be revoked.""" from datetime import UTC, datetime async with client_factory.get_db_session() as db: godfather = await create_user_with_roles( db, unique_email("godfather"), "password123", [ROLE_REGULAR] ) invite = Invite( identifier="revoke-test-01", godfather_id=godfather.id, status=InviteStatus.READY, ) db.add(invite) await db.commit() # Revoke invite.status = InviteStatus.REVOKED invite.revoked_at = datetime.now(UTC) await db.commit() await db.refresh(invite) assert invite.status == InviteStatus.REVOKED assert invite.revoked_at is not None assert invite.used_by_id is None # Not used # ============================================================================ # User Godfather Tests # ============================================================================ @pytest.mark.asyncio async def test_user_godfather_relationship(client_factory): """User can have a godfather.""" async with client_factory.get_db_session() as db: godfather = await create_user_with_roles( db, unique_email("godfather"), "password123", [ROLE_REGULAR] ) # Create user with godfather user = User( email=unique_email("godchild"), hashed_password="hashed", godfather_id=godfather.id, ) db.add(user) await db.commit() # Query user fresh result = await db.execute(select(User).where(User.id == user.id)) loaded_user = result.scalar_one() assert loaded_user.godfather_id == godfather.id assert loaded_user.godfather is not None assert loaded_user.godfather.email == godfather.email @pytest.mark.asyncio async def test_user_without_godfather(client_factory): """User can exist without godfather (for seeded/admin users).""" async with client_factory.get_db_session() as db: user = await create_user_with_roles( db, unique_email("noparent"), "password123", [ROLE_REGULAR] ) assert user.godfather_id is None assert user.godfather is None # ============================================================================ # Admin Create Invite API Tests (Phase 2) # ============================================================================ @pytest.mark.asyncio async def test_admin_can_create_invite(client_factory, admin_user, regular_user): """Admin can create an invite for a regular user.""" async with client_factory.create(cookies=admin_user["cookies"]) as client: # Get regular user ID async with client_factory.get_db_session() as db: result = await db.execute( select(User).where(User.email == regular_user["email"]) ) godfather = result.scalar_one() response = await client.post( "/api/admin/invites", json={"godfather_id": godfather.id}, ) assert response.status_code == 200 data = response.json() assert data["godfather_id"] == godfather.id assert data["godfather_email"] == regular_user["email"] assert data["status"] == "ready" assert data["used_by_id"] is None assert data["used_by_email"] is None assert data["spent_at"] is None assert data["revoked_at"] is None assert "-" in data["identifier"] # word-word-NN format @pytest.mark.asyncio async def test_admin_can_create_invite_for_self(client_factory, admin_user): """Admin can create an invite for themselves.""" async with client_factory.create(cookies=admin_user["cookies"]) as client: # Get admin user ID async with client_factory.get_db_session() as db: result = await db.execute( select(User).where(User.email == admin_user["email"]) ) admin = result.scalar_one() response = await client.post( "/api/admin/invites", json={"godfather_id": admin.id}, ) assert response.status_code == 200 data = response.json() assert data["godfather_id"] == admin.id assert data["godfather_email"] == admin_user["email"] @pytest.mark.asyncio async def test_regular_user_cannot_create_invite(client_factory, regular_user): """Regular user cannot create invites (403).""" async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post( "/api/admin/invites", json={"godfather_id": 1}, ) assert response.status_code == 403 @pytest.mark.asyncio async def test_unauthenticated_cannot_create_invite(client_factory): """Unauthenticated user cannot create invites (401).""" async with client_factory.create() as client: response = await client.post( "/api/admin/invites", json={"godfather_id": 1}, ) assert response.status_code == 401 @pytest.mark.asyncio async def test_create_invite_invalid_godfather(client_factory, admin_user): """Creating invite with non-existent godfather returns 400.""" async with client_factory.create(cookies=admin_user["cookies"]) as client: response = await client.post( "/api/admin/invites", json={"godfather_id": 99999}, ) assert response.status_code == 400 assert "not found" in response.json()["detail"].lower() @pytest.mark.asyncio async def test_created_invite_persisted_in_db(client_factory, admin_user, regular_user): """Created invite is persisted in database.""" async with client_factory.create(cookies=admin_user["cookies"]) as client: async with client_factory.get_db_session() as db: result = await db.execute( select(User).where(User.email == regular_user["email"]) ) godfather = result.scalar_one() response = await client.post( "/api/admin/invites", json={"godfather_id": godfather.id}, ) data = response.json() invite_id = data["id"] # Query from DB async with client_factory.get_db_session() as db: result = await db.execute(select(Invite).where(Invite.id == invite_id)) invite = result.scalar_one() assert invite.identifier == data["identifier"] assert invite.godfather_id == godfather.id assert invite.status == InviteStatus.READY @pytest.mark.asyncio async def test_create_invite_retries_on_collision( client_factory, admin_user, regular_user ): """Create invite retries with new identifier on collision.""" from unittest.mock import patch async with client_factory.create(cookies=admin_user["cookies"]) as client: async with client_factory.get_db_session() as db: result = await db.execute( select(User).where(User.email == regular_user["email"]) ) godfather = result.scalar_one() # Create first invite normally response1 = await client.post( "/api/admin/invites", json={"godfather_id": godfather.id}, ) assert response1.status_code == 200 identifier1 = response1.json()["identifier"] # Mock generator to first return the same identifier (collision), then a new one call_count = 0 def mock_generator(): nonlocal call_count call_count += 1 if call_count == 1: return identifier1 # Will collide return f"unique-word-{call_count:02d}" # Won't collide with patch( "services.invite.generate_invite_identifier", side_effect=mock_generator ): response2 = await client.post( "/api/admin/invites", json={"godfather_id": godfather.id}, ) assert response2.status_code == 200 # Should have retried and gotten a new identifier assert response2.json()["identifier"] != identifier1 assert call_count >= 2 # At least one retry # ============================================================================ # Invite Check API Tests (Phase 3) # ============================================================================ @pytest.mark.asyncio async def test_check_invite_valid(client_factory, admin_user, regular_user): """Check endpoint returns valid=True for READY invite.""" # Create invite async with client_factory.create(cookies=admin_user["cookies"]) as client: async with client_factory.get_db_session() as db: result = await db.execute( select(User).where(User.email == regular_user["email"]) ) godfather = result.scalar_one() create_resp = await client.post( "/api/admin/invites", json={"godfather_id": godfather.id}, ) identifier = create_resp.json()["identifier"] # Check invite (no auth needed) async with client_factory.create() as client: response = await client.get(f"/api/invites/{identifier}/check") assert response.status_code == 200 data = response.json() assert data["valid"] is True assert data["status"] == "ready" assert data["error"] is None @pytest.mark.asyncio async def test_check_invite_not_found(client_factory): """Check endpoint returns valid=False for unknown invite.""" async with client_factory.create() as client: response = await client.get("/api/invites/fake-invite-99/check") assert response.status_code == 200 data = response.json() assert data["valid"] is False assert "not found" in data["error"].lower() @pytest.mark.asyncio async def test_check_invite_invalid_format(client_factory): """Check endpoint returns error for invalid format without querying DB.""" async with client_factory.create() as client: # Missing number part response = await client.get("/api/invites/word-word/check") assert response.status_code == 200 data = response.json() assert data["valid"] is False assert "format" in data["error"].lower() # Single digit number response = await client.get("/api/invites/word-word-1/check") assert response.status_code == 200 data = response.json() assert data["valid"] is False assert "format" in data["error"].lower() # Too many parts response = await client.get("/api/invites/word-word-word-00/check") assert response.status_code == 200 data = response.json() assert data["valid"] is False assert "format" in data["error"].lower() @pytest.mark.asyncio async def test_check_invite_spent_returns_not_found( client_factory, admin_user, regular_user ): """Check endpoint returns same error for spent invite as for non-existent (no info leakage).""" # Create invite async with client_factory.create(cookies=admin_user["cookies"]) as client: async with client_factory.get_db_session() as db: result = await db.execute( select(User).where(User.email == regular_user["email"]) ) godfather = result.scalar_one() create_resp = await client.post( "/api/admin/invites", json={"godfather_id": godfather.id}, ) identifier = create_resp.json()["identifier"] # Use the invite async with client_factory.create() as client: await client.post( "/api/auth/register", json={ "email": unique_email("spentcheck"), "password": "password123", "invite_identifier": identifier, }, ) # Check spent invite - should return same error as non-existent async with client_factory.create() as client: response = await client.get(f"/api/invites/{identifier}/check") assert response.status_code == 200 data = response.json() assert data["valid"] is False assert "not found" in data["error"].lower() @pytest.mark.asyncio async def test_check_invite_revoked_returns_not_found( client_factory, admin_user, regular_user ): """Check endpoint returns same error for revoked invite as for non-existent (no info leakage).""" from datetime import UTC, datetime # Create invite async with client_factory.create(cookies=admin_user["cookies"]) as client: async with client_factory.get_db_session() as db: result = await db.execute( select(User).where(User.email == regular_user["email"]) ) godfather = result.scalar_one() create_resp = await client.post( "/api/admin/invites", json={"godfather_id": godfather.id}, ) identifier = create_resp.json()["identifier"] invite_id = create_resp.json()["id"] # Revoke the invite async with client_factory.get_db_session() as db: result = await db.execute(select(Invite).where(Invite.id == invite_id)) invite = result.scalar_one() invite.status = InviteStatus.REVOKED invite.revoked_at = datetime.now(UTC) await db.commit() # Check revoked invite - should return same error as non-existent async with client_factory.create() as client: response = await client.get(f"/api/invites/{identifier}/check") assert response.status_code == 200 data = response.json() assert data["valid"] is False assert "not found" in data["error"].lower() @pytest.mark.asyncio async def test_check_invite_case_insensitive(client_factory, admin_user, regular_user): """Check endpoint handles case-insensitive identifiers.""" # Create invite async with client_factory.create(cookies=admin_user["cookies"]) as client: async with client_factory.get_db_session() as db: result = await db.execute( select(User).where(User.email == regular_user["email"]) ) godfather = result.scalar_one() create_resp = await client.post( "/api/admin/invites", json={"godfather_id": godfather.id}, ) identifier = create_resp.json()["identifier"] # Check with uppercase async with client_factory.create() as client: response = await client.get(f"/api/invites/{identifier.upper()}/check") assert response.status_code == 200 assert response.json()["valid"] is True # ============================================================================ # Register with Invite Tests (Phase 3) # ============================================================================ @pytest.mark.asyncio async def test_register_with_valid_invite(client_factory, admin_user, regular_user): """Can register with valid invite code.""" # Create invite async with client_factory.create(cookies=admin_user["cookies"]) as client: async with client_factory.get_db_session() as db: result = await db.execute( select(User).where(User.email == regular_user["email"]) ) godfather = result.scalar_one() godfather_id = godfather.id create_resp = await client.post( "/api/admin/invites", json={"godfather_id": godfather_id}, ) identifier = create_resp.json()["identifier"] # Register with invite new_email = unique_email("newuser") async with client_factory.create() as client: response = await client.post( "/api/auth/register", json={ "email": new_email, "password": "password123", "invite_identifier": identifier, }, ) assert response.status_code == 200 data = response.json() assert data["email"] == new_email assert "regular" in data["roles"] @pytest.mark.asyncio async def test_register_marks_invite_spent(client_factory, admin_user, regular_user): """Registering marks the invite as SPENT.""" # Create invite async with client_factory.create(cookies=admin_user["cookies"]) as client: async with client_factory.get_db_session() as db: result = await db.execute( select(User).where(User.email == regular_user["email"]) ) godfather = result.scalar_one() create_resp = await client.post( "/api/admin/invites", json={"godfather_id": godfather.id}, ) invite_data = create_resp.json() identifier = invite_data["identifier"] invite_id = invite_data["id"] # Register async with client_factory.create() as client: await client.post( "/api/auth/register", json={ "email": unique_email("spenttest"), "password": "password123", "invite_identifier": identifier, }, ) # Check invite status async with client_factory.get_db_session() as db: result = await db.execute(select(Invite).where(Invite.id == invite_id)) invite = result.scalar_one() assert invite.status == InviteStatus.SPENT assert invite.used_by_id is not None assert invite.spent_at is not None @pytest.mark.asyncio async def test_register_sets_godfather(client_factory, admin_user, regular_user): """New user has correct godfather_id.""" # Create invite async with client_factory.create(cookies=admin_user["cookies"]) as client: async with client_factory.get_db_session() as db: result = await db.execute( select(User).where(User.email == regular_user["email"]) ) godfather = result.scalar_one() godfather_id = godfather.id create_resp = await client.post( "/api/admin/invites", json={"godfather_id": godfather_id}, ) identifier = create_resp.json()["identifier"] # Register new_email = unique_email("godchildtest") async with client_factory.create() as client: await client.post( "/api/auth/register", json={ "email": new_email, "password": "password123", "invite_identifier": identifier, }, ) # Check user's godfather async with client_factory.get_db_session() as db: result = await db.execute(select(User).where(User.email == new_email)) new_user = result.scalar_one() assert new_user.godfather_id == godfather_id @pytest.mark.asyncio async def test_register_with_invalid_invite(client_factory): """Cannot register with non-existent invite.""" async with client_factory.create() as client: response = await client.post( "/api/auth/register", json={ "email": unique_email("invalid"), "password": "password123", "invite_identifier": "fake-invite-99", }, ) assert response.status_code == 400 assert "invalid" in response.json()["detail"].lower() @pytest.mark.asyncio async def test_register_with_spent_invite(client_factory, admin_user, regular_user): """Cannot register with already-spent invite.""" # Create and use invite async with client_factory.create(cookies=admin_user["cookies"]) as client: async with client_factory.get_db_session() as db: result = await db.execute( select(User).where(User.email == regular_user["email"]) ) godfather = result.scalar_one() create_resp = await client.post( "/api/admin/invites", json={"godfather_id": godfather.id}, ) identifier = create_resp.json()["identifier"] # First registration async with client_factory.create() as client: await client.post( "/api/auth/register", json={ "email": unique_email("first"), "password": "password123", "invite_identifier": identifier, }, ) # Second registration with same invite async with client_factory.create() as client: response = await client.post( "/api/auth/register", json={ "email": unique_email("second"), "password": "password123", "invite_identifier": identifier, }, ) assert response.status_code == 400 assert "invalid invite code" in response.json()["detail"].lower() @pytest.mark.asyncio async def test_register_with_revoked_invite(client_factory, admin_user, regular_user): """Cannot register with revoked invite.""" from datetime import UTC, datetime # Create invite async with client_factory.create(cookies=admin_user["cookies"]) as client: async with client_factory.get_db_session() as db: result = await db.execute( select(User).where(User.email == regular_user["email"]) ) godfather = result.scalar_one() create_resp = await client.post( "/api/admin/invites", json={"godfather_id": godfather.id}, ) invite_data = create_resp.json() identifier = invite_data["identifier"] invite_id = invite_data["id"] # Revoke invite directly in DB async with client_factory.get_db_session() as db: result = await db.execute(select(Invite).where(Invite.id == invite_id)) invite = result.scalar_one() invite.status = InviteStatus.REVOKED invite.revoked_at = datetime.now(UTC) await db.commit() # Try to register async with client_factory.create() as client: response = await client.post( "/api/auth/register", json={ "email": unique_email("revoked"), "password": "password123", "invite_identifier": identifier, }, ) assert response.status_code == 400 assert "invalid invite code" in response.json()["detail"].lower() @pytest.mark.asyncio async def test_register_duplicate_email(client_factory, admin_user, regular_user): """Cannot register with already-used email.""" # Create invite async with client_factory.create(cookies=admin_user["cookies"]) as client: async with client_factory.get_db_session() as db: result = await db.execute( select(User).where(User.email == regular_user["email"]) ) godfather = result.scalar_one() create_resp = await client.post( "/api/admin/invites", json={"godfather_id": godfather.id}, ) identifier = create_resp.json()["identifier"] # Try to register with existing email async with client_factory.create() as client: response = await client.post( "/api/auth/register", json={ "email": regular_user["email"], # Already exists "password": "password123", "invite_identifier": identifier, }, ) assert response.status_code == 400 assert "already registered" in response.json()["detail"].lower() @pytest.mark.asyncio async def test_register_sets_auth_cookie(client_factory, admin_user, regular_user): """Registration sets auth cookie.""" # Create invite async with client_factory.create(cookies=admin_user["cookies"]) as client: async with client_factory.get_db_session() as db: result = await db.execute( select(User).where(User.email == regular_user["email"]) ) godfather = result.scalar_one() create_resp = await client.post( "/api/admin/invites", json={"godfather_id": godfather.id}, ) identifier = create_resp.json()["identifier"] # Register async with client_factory.create() as client: response = await client.post( "/api/auth/register", json={ "email": unique_email("cookietest"), "password": "password123", "invite_identifier": identifier, }, ) assert "auth_token" in response.cookies # ============================================================================ # User Invites API Tests (Phase 4) # ============================================================================ @pytest.mark.asyncio async def test_regular_user_can_list_invites(client_factory, admin_user, regular_user): """Regular user can list their own invites.""" # Create invites for the regular user async with client_factory.create(cookies=admin_user["cookies"]) as client: async with client_factory.get_db_session() as db: result = await db.execute( select(User).where(User.email == regular_user["email"]) ) godfather = result.scalar_one() await client.post("/api/admin/invites", json={"godfather_id": godfather.id}) await client.post("/api/admin/invites", json={"godfather_id": godfather.id}) # List invites as regular user async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.get("/api/invites") assert response.status_code == 200 invites = response.json() assert len(invites) == 2 for invite in invites: assert "identifier" in invite assert invite["status"] == "ready" @pytest.mark.asyncio async def test_user_with_no_invites_gets_empty_list(client_factory, regular_user): """User with no invites gets empty list.""" async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.get("/api/invites") assert response.status_code == 200 assert response.json() == [] @pytest.mark.asyncio async def test_spent_invite_shows_used_by_email( client_factory, admin_user, regular_user ): """Spent invite shows who used it.""" # Create invite for regular user async with client_factory.create(cookies=admin_user["cookies"]) as client: async with client_factory.get_db_session() as db: result = await db.execute( select(User).where(User.email == regular_user["email"]) ) godfather = result.scalar_one() create_resp = await client.post( "/api/admin/invites", json={"godfather_id": godfather.id}, ) identifier = create_resp.json()["identifier"] # Use the invite invitee_email = unique_email("invitee") async with client_factory.create() as client: await client.post( "/api/auth/register", json={ "email": invitee_email, "password": "password123", "invite_identifier": identifier, }, ) # Check that regular user sees the invitee email async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.get("/api/invites") assert response.status_code == 200 invites = response.json() assert len(invites) == 1 assert invites[0]["status"] == "spent" assert invites[0]["used_by_email"] == invitee_email @pytest.mark.asyncio async def test_admin_cannot_list_own_invites(client_factory, admin_user): """Admin without VIEW_OWN_INVITES permission gets 403.""" async with client_factory.create(cookies=admin_user["cookies"]) as client: response = await client.get("/api/invites") assert response.status_code == 403 @pytest.mark.asyncio async def test_unauthenticated_cannot_list_invites(client_factory): """Unauthenticated user gets 401.""" async with client_factory.create() as client: response = await client.get("/api/invites") assert response.status_code == 401 # ============================================================================ # Admin Invite Management Tests (Phase 5) # ============================================================================ @pytest.mark.asyncio async def test_admin_can_list_all_invites(client_factory, admin_user, regular_user): """Admin can list all invites.""" # Create some invites async with client_factory.create(cookies=admin_user["cookies"]) as client: async with client_factory.get_db_session() as db: result = await db.execute( select(User).where(User.email == regular_user["email"]) ) godfather = result.scalar_one() await client.post("/api/admin/invites", json={"godfather_id": godfather.id}) await client.post("/api/admin/invites", json={"godfather_id": godfather.id}) # List all response = await client.get("/api/admin/invites") assert response.status_code == 200 data = response.json() assert data["total"] >= 2 assert len(data["records"]) >= 2 @pytest.mark.asyncio async def test_admin_list_pagination(client_factory, admin_user, regular_user): """Admin can paginate invite list.""" async with client_factory.create(cookies=admin_user["cookies"]) as client: async with client_factory.get_db_session() as db: result = await db.execute( select(User).where(User.email == regular_user["email"]) ) godfather = result.scalar_one() # Create 5 invites for _ in range(5): await client.post("/api/admin/invites", json={"godfather_id": godfather.id}) # Get page 1 with 2 per page response = await client.get("/api/admin/invites?page=1&per_page=2") assert response.status_code == 200 data = response.json() assert len(data["records"]) == 2 assert data["page"] == 1 assert data["per_page"] == 2 @pytest.mark.asyncio async def test_admin_filter_by_status(client_factory, admin_user, regular_user): """Admin can filter invites by status.""" async with client_factory.create(cookies=admin_user["cookies"]) as client: async with client_factory.get_db_session() as db: result = await db.execute( select(User).where(User.email == regular_user["email"]) ) godfather = result.scalar_one() # Create an invite await client.post("/api/admin/invites", json={"godfather_id": godfather.id}) # Filter by ready response = await client.get("/api/admin/invites?status=ready") assert response.status_code == 200 data = response.json() for record in data["records"]: assert record["status"] == "ready" @pytest.mark.asyncio async def test_admin_can_revoke_invite(client_factory, admin_user, regular_user): """Admin can revoke a READY invite.""" async with client_factory.create(cookies=admin_user["cookies"]) as client: async with client_factory.get_db_session() as db: result = await db.execute( select(User).where(User.email == regular_user["email"]) ) godfather = result.scalar_one() # Create invite create_resp = await client.post( "/api/admin/invites", json={"godfather_id": godfather.id}, ) invite_id = create_resp.json()["id"] # Revoke it response = await client.post(f"/api/admin/invites/{invite_id}/revoke") assert response.status_code == 200 data = response.json() assert data["status"] == "revoked" assert data["revoked_at"] is not None @pytest.mark.asyncio async def test_cannot_revoke_spent_invite(client_factory, admin_user, regular_user): """Cannot revoke an already-spent invite.""" async with client_factory.create(cookies=admin_user["cookies"]) as client: async with client_factory.get_db_session() as db: result = await db.execute( select(User).where(User.email == regular_user["email"]) ) godfather = result.scalar_one() # Create invite create_resp = await client.post( "/api/admin/invites", json={"godfather_id": godfather.id}, ) invite_data = create_resp.json() # Use the invite async with client_factory.create() as client: await client.post( "/api/auth/register", json={ "email": unique_email("spent"), "password": "password123", "invite_identifier": invite_data["identifier"], }, ) # Try to revoke async with client_factory.create(cookies=admin_user["cookies"]) as client: response = await client.post(f"/api/admin/invites/{invite_data['id']}/revoke") assert response.status_code == 400 assert "only ready" in response.json()["detail"].lower() @pytest.mark.asyncio async def test_revoke_nonexistent_invite(client_factory, admin_user): """Revoking non-existent invite returns 404.""" async with client_factory.create(cookies=admin_user["cookies"]) as client: response = await client.post("/api/admin/invites/99999/revoke") assert response.status_code == 404 @pytest.mark.asyncio async def test_regular_user_cannot_access_admin_invites(client_factory, regular_user): """Regular user cannot access admin invite endpoints.""" async with client_factory.create(cookies=regular_user["cookies"]) as client: # List response = await client.get("/api/admin/invites") assert response.status_code == 403 # Revoke response = await client.post("/api/admin/invites/1/revoke") assert response.status_code == 403