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:
parent
69bc8413e0
commit
6c218130e9
31 changed files with 1234 additions and 876 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue