Add ruff linter/formatter for Python

- Add ruff as dev dependency
- Configure ruff in pyproject.toml with strict 88-char line limit
- Ignore B008 (FastAPI Depends pattern is standard)
- Allow longer lines in tests for readability
- Fix all lint issues in source files
- Add Makefile targets: lint-backend, format-backend, fix-backend
This commit is contained in:
counterweight 2025-12-21 21:54:26 +01:00
parent 69bc8413e0
commit 6c218130e9
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
31 changed files with 1234 additions and 876 deletions

View file

@ -1,22 +1,23 @@
"""Tests for invite functionality."""
import pytest
from sqlalchemy import select
from invite_utils import (
generate_invite_identifier,
normalize_identifier,
is_valid_identifier_format,
BIP39_WORDS,
generate_invite_identifier,
is_valid_identifier_format,
normalize_identifier,
)
from models import Invite, InviteStatus, User, ROLE_REGULAR
from tests.helpers import unique_email
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
@ -26,7 +27,7 @@ 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
@ -74,11 +75,11 @@ def test_is_valid_identifier_format_invalid():
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
@ -89,6 +90,7 @@ def test_is_valid_identifier_format_invalid():
# Invite Model Tests
# ============================================================================
@pytest.mark.asyncio
async def test_create_invite(client_factory):
"""Can create an invite with godfather."""
@ -97,7 +99,7 @@ async def test_create_invite(client_factory):
godfather = await create_user_with_roles(
db, unique_email("godfather"), "password123", [ROLE_REGULAR]
)
# Create invite
invite = Invite(
identifier="test-invite-01",
@ -107,7 +109,7 @@ async def test_create_invite(client_factory):
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
@ -125,20 +127,20 @@ async def test_invite_godfather_relationship(client_factory):
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
@ -147,25 +149,25 @@ async def test_invite_godfather_relationship(client_factory):
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()
@ -173,8 +175,8 @@ async def test_invite_unique_identifier(client_factory):
@pytest.mark.asyncio
async def test_invite_status_transitions(client_factory):
"""Invite status can be changed."""
from datetime import datetime, UTC
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]
@ -182,7 +184,7 @@ async def test_invite_status_transitions(client_factory):
user = await create_user_with_roles(
db, unique_email("invitee"), "password123", [ROLE_REGULAR]
)
invite = Invite(
identifier="status-test-01",
godfather_id=godfather.id,
@ -190,14 +192,14 @@ async def test_invite_status_transitions(client_factory):
)
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
@ -206,13 +208,13 @@ async def test_invite_status_transitions(client_factory):
@pytest.mark.asyncio
async def test_invite_revoke(client_factory):
"""Invite can be revoked."""
from datetime import datetime, UTC
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,
@ -220,13 +222,13 @@ async def test_invite_revoke(client_factory):
)
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
@ -236,6 +238,7 @@ async def test_invite_revoke(client_factory):
# User Godfather Tests
# ============================================================================
@pytest.mark.asyncio
async def test_user_godfather_relationship(client_factory):
"""User can have a godfather."""
@ -243,7 +246,7 @@ async def test_user_godfather_relationship(client_factory):
godfather = await create_user_with_roles(
db, unique_email("godfather"), "password123", [ROLE_REGULAR]
)
# Create user with godfather
user = User(
email=unique_email("godchild"),
@ -252,13 +255,11 @@ async def test_user_godfather_relationship(client_factory):
)
db.add(user)
await db.commit()
# Query user fresh
result = await db.execute(
select(User).where(User.id == user.id)
)
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
@ -271,7 +272,7 @@ async def test_user_without_godfather(client_factory):
user = await create_user_with_roles(
db, unique_email("noparent"), "password123", [ROLE_REGULAR]
)
assert user.godfather_id is None
assert user.godfather is None
@ -280,6 +281,7 @@ async def test_user_without_godfather(client_factory):
# 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."""
@ -290,12 +292,12 @@ async def test_admin_can_create_invite(client_factory, admin_user, regular_user)
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
@ -318,12 +320,12 @@ async def test_admin_can_create_invite_for_self(client_factory, admin_user):
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
@ -338,7 +340,7 @@ async def test_regular_user_cannot_create_invite(client_factory, regular_user):
"/api/admin/invites",
json={"godfather_id": 1},
)
assert response.status_code == 403
@ -350,7 +352,7 @@ async def test_unauthenticated_cannot_create_invite(client_factory):
"/api/admin/invites",
json={"godfather_id": 1},
)
assert response.status_code == 401
@ -362,7 +364,7 @@ async def test_create_invite_invalid_godfather(client_factory, admin_user):
"/api/admin/invites",
json={"godfather_id": 99999},
)
assert response.status_code == 400
assert "not found" in response.json()["detail"].lower()
@ -376,39 +378,39 @@ async def test_created_invite_persisted_in_db(client_factory, admin_user, regula
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)
)
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):
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",
@ -416,22 +418,25 @@ async def test_create_invite_retries_on_collision(client_factory, admin_user, re
)
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("routes.invites.generate_invite_identifier", side_effect=mock_generator):
with patch(
"routes.invites.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
@ -442,6 +447,7 @@ async def test_create_invite_retries_on_collision(client_factory, admin_user, re
# 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."""
@ -452,17 +458,17 @@ async def test_check_invite_valid(client_factory, admin_user, regular_user):
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
@ -475,7 +481,7 @@ 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
@ -492,14 +498,14 @@ async def test_check_invite_invalid_format(client_factory):
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
@ -509,7 +515,9 @@ async def test_check_invite_invalid_format(client_factory):
@pytest.mark.asyncio
async def test_check_invite_spent_returns_not_found(client_factory, admin_user, regular_user):
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:
@ -518,13 +526,13 @@ async def test_check_invite_spent_returns_not_found(client_factory, admin_user,
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(
@ -535,11 +543,11 @@ async def test_check_invite_spent_returns_not_found(client_factory, admin_user,
"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
@ -547,10 +555,12 @@ async def test_check_invite_spent_returns_not_found(client_factory, admin_user,
@pytest.mark.asyncio
async def test_check_invite_revoked_returns_not_found(client_factory, admin_user, regular_user):
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 datetime, UTC
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:
@ -558,14 +568,14 @@ async def test_check_invite_revoked_returns_not_found(client_factory, admin_user
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))
@ -573,11 +583,11 @@ async def test_check_invite_revoked_returns_not_found(client_factory, admin_user
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
@ -594,17 +604,17 @@ async def test_check_invite_case_insensitive(client_factory, admin_user, regular
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
@ -613,6 +623,7 @@ async def test_check_invite_case_insensitive(client_factory, admin_user, regular
# 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."""
@ -624,13 +635,13 @@ async def test_register_with_valid_invite(client_factory, admin_user, regular_us
)
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:
@ -642,7 +653,7 @@ async def test_register_with_valid_invite(client_factory, admin_user, regular_us
"invite_identifier": identifier,
},
)
assert response.status_code == 200
data = response.json()
assert data["email"] == new_email
@ -659,7 +670,7 @@ async def test_register_marks_invite_spent(client_factory, admin_user, regular_u
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},
@ -667,7 +678,7 @@ async def test_register_marks_invite_spent(client_factory, admin_user, regular_u
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(
@ -678,14 +689,12 @@ async def test_register_marks_invite_spent(client_factory, admin_user, regular_u
"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)
)
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
@ -702,13 +711,13 @@ async def test_register_sets_godfather(client_factory, admin_user, regular_user)
)
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:
@ -720,14 +729,12 @@ async def test_register_sets_godfather(client_factory, admin_user, regular_user)
"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)
)
result = await db.execute(select(User).where(User.email == new_email))
new_user = result.scalar_one()
assert new_user.godfather_id == godfather_id
@ -743,7 +750,7 @@ async def test_register_with_invalid_invite(client_factory):
"invite_identifier": "fake-invite-99",
},
)
assert response.status_code == 400
assert "invalid" in response.json()["detail"].lower()
@ -758,13 +765,13 @@ async def test_register_with_spent_invite(client_factory, admin_user, regular_us
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(
@ -775,7 +782,7 @@ async def test_register_with_spent_invite(client_factory, admin_user, regular_us
"invite_identifier": identifier,
},
)
# Second registration with same invite
async with client_factory.create() as client:
response = await client.post(
@ -786,7 +793,7 @@ async def test_register_with_spent_invite(client_factory, admin_user, regular_us
"invite_identifier": identifier,
},
)
assert response.status_code == 400
assert "invalid invite code" in response.json()["detail"].lower()
@ -794,8 +801,8 @@ async def test_register_with_spent_invite(client_factory, admin_user, regular_us
@pytest.mark.asyncio
async def test_register_with_revoked_invite(client_factory, admin_user, regular_user):
"""Cannot register with revoked invite."""
from datetime import datetime, UTC
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:
@ -803,7 +810,7 @@ async def test_register_with_revoked_invite(client_factory, admin_user, regular_
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},
@ -811,17 +818,15 @@ async def test_register_with_revoked_invite(client_factory, admin_user, regular_
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)
)
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(
@ -832,7 +837,7 @@ async def test_register_with_revoked_invite(client_factory, admin_user, regular_
"invite_identifier": identifier,
},
)
assert response.status_code == 400
assert "invalid invite code" in response.json()["detail"].lower()
@ -847,13 +852,13 @@ async def test_register_duplicate_email(client_factory, admin_user, regular_user
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(
@ -864,7 +869,7 @@ async def test_register_duplicate_email(client_factory, admin_user, regular_user
"invite_identifier": identifier,
},
)
assert response.status_code == 400
assert "already registered" in response.json()["detail"].lower()
@ -879,13 +884,13 @@ async def test_register_sets_auth_cookie(client_factory, admin_user, regular_use
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(
@ -896,7 +901,7 @@ async def test_register_sets_auth_cookie(client_factory, admin_user, regular_use
"invite_identifier": identifier,
},
)
assert "auth_token" in response.cookies
@ -904,6 +909,7 @@ async def test_register_sets_auth_cookie(client_factory, admin_user, regular_use
# 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."""
@ -914,14 +920,14 @@ async def test_regular_user_can_list_invites(client_factory, admin_user, regular
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
@ -935,13 +941,15 @@ 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):
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:
@ -950,13 +958,13 @@ async def test_spent_invite_shows_used_by_email(client_factory, admin_user, regu
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:
@ -968,11 +976,11 @@ async def test_spent_invite_shows_used_by_email(client_factory, admin_user, regu
"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
@ -985,7 +993,7 @@ 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
@ -994,7 +1002,7 @@ 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
@ -1002,6 +1010,7 @@ async def test_unauthenticated_cannot_list_invites(client_factory):
# 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."""
@ -1012,13 +1021,13 @@ async def test_admin_can_list_all_invites(client_factory, admin_user, regular_us
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
@ -1034,14 +1043,14 @@ async def test_admin_list_pagination(client_factory, admin_user, regular_user):
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
@ -1058,13 +1067,13 @@ async def test_admin_filter_by_status(client_factory, admin_user, regular_user):
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"]:
@ -1080,17 +1089,17 @@ async def test_admin_can_revoke_invite(client_factory, admin_user, regular_user)
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"
@ -1106,14 +1115,14 @@ async def test_cannot_revoke_spent_invite(client_factory, admin_user, regular_us
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(
@ -1124,11 +1133,11 @@ async def test_cannot_revoke_spent_invite(client_factory, admin_user, regular_us
"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()
@ -1138,7 +1147,7 @@ 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
@ -1149,8 +1158,7 @@ async def test_regular_user_cannot_access_admin_invites(client_factory, regular_
# 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