Initial commit: Pole Dance Championships App
Full-stack mobile app for pole dance championship management. Backend: FastAPI + SQLAlchemy 2 (async) + SQLite (dev) / PostgreSQL (prod) - JWT auth with refresh token rotation - Championship CRUD with Instagram Graph API sync (APScheduler) - Registration flow with status management - Participant list publish with Expo push notifications - Alembic migrations, pytest test suite Mobile: React Native + Expo (TypeScript) - Auth gate: pending approval screen for new members - Championships list & detail screens - Registration form with status tracking - React Query + Zustand + React Navigation v6 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
55
backend/tests/conftest.py
Normal file
55
backend/tests/conftest.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
# Override DATABASE_URL before any app code is imported so the lazy engine
|
||||
# initialises with SQLite (no asyncpg required in the test environment).
|
||||
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||
|
||||
from app.database import Base, get_db
|
||||
from app.main import app
|
||||
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
loop = asyncio.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session")
|
||||
async def db_engine():
|
||||
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session(db_engine):
|
||||
factory = async_sessionmaker(db_engine, expire_on_commit=False)
|
||||
async with factory() as session:
|
||||
yield session
|
||||
await session.rollback()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(db_session):
|
||||
async def override_get_db():
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
89
backend/tests/test_auth.py
Normal file
89
backend/tests/test_auth.py
Normal file
@@ -0,0 +1,89 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_and_login(client):
|
||||
# Register
|
||||
res = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "test@example.com",
|
||||
"password": "secret123",
|
||||
"full_name": "Test User",
|
||||
},
|
||||
)
|
||||
assert res.status_code == 201
|
||||
tokens = res.json()
|
||||
assert "access_token" in tokens
|
||||
assert "refresh_token" in tokens
|
||||
|
||||
# Duplicate registration should fail
|
||||
res2 = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "test@example.com",
|
||||
"password": "secret123",
|
||||
"full_name": "Test User",
|
||||
},
|
||||
)
|
||||
assert res2.status_code == 409
|
||||
|
||||
# Login with correct credentials
|
||||
res3 = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "test@example.com", "password": "secret123"},
|
||||
)
|
||||
assert res3.status_code == 200
|
||||
|
||||
# Login with wrong password
|
||||
res4 = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "test@example.com", "password": "wrong"},
|
||||
)
|
||||
assert res4.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_me_requires_auth(client):
|
||||
res = await client.get("/api/v1/auth/me")
|
||||
assert res.status_code in (401, 403) # missing Authorization header
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_user_cannot_access_championships(client):
|
||||
await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "pending@example.com", "password": "pw", "full_name": "Pending"},
|
||||
)
|
||||
login = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "pending@example.com", "password": "pw"},
|
||||
)
|
||||
token = login.json()["access_token"]
|
||||
res = await client.get(
|
||||
"/api/v1/championships",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert res.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_refresh(client):
|
||||
await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "refresh@example.com", "password": "pw", "full_name": "Refresh"},
|
||||
)
|
||||
login = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "refresh@example.com", "password": "pw"},
|
||||
)
|
||||
refresh_token = login.json()["refresh_token"]
|
||||
|
||||
res = await client.post("/api/v1/auth/refresh", json={"refresh_token": refresh_token})
|
||||
assert res.status_code == 200
|
||||
new_tokens = res.json()
|
||||
assert "access_token" in new_tokens
|
||||
|
||||
# Old refresh token should now be revoked
|
||||
res2 = await client.post("/api/v1/auth/refresh", json={"refresh_token": refresh_token})
|
||||
assert res2.status_code == 401
|
||||
46
backend/tests/test_instagram_parser.py
Normal file
46
backend/tests/test_instagram_parser.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.services.instagram_service import parse_caption
|
||||
|
||||
|
||||
def test_parse_basic_russian_post():
|
||||
text = """Открытый Чемпионат по Pole Dance
|
||||
Место: Москва, ул. Арбат, 10
|
||||
Дата: 15 марта 2026
|
||||
Регистрация открыта!"""
|
||||
result = parse_caption(text)
|
||||
assert result.title == "Открытый Чемпионат по Pole Dance"
|
||||
assert result.location == "Москва, ул. Арбат, 10"
|
||||
assert result.event_date == datetime(2026, 3, 15, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_parse_dot_date_format():
|
||||
text = "Summer Cup\nLocation: Saint Petersburg\n15.07.2026"
|
||||
result = parse_caption(text)
|
||||
assert result.event_date == datetime(2026, 7, 15, tzinfo=timezone.utc)
|
||||
assert result.location == "Saint Petersburg"
|
||||
|
||||
|
||||
def test_parse_english_date():
|
||||
text = "Winter Championship\nVenue: Moscow Arena\nJanuary 20, 2027"
|
||||
result = parse_caption(text)
|
||||
assert result.event_date == datetime(2027, 1, 20, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_parse_no_date_returns_none():
|
||||
text = "Some announcement\nNo date here"
|
||||
result = parse_caption(text)
|
||||
assert result.event_date is None
|
||||
assert result.title == "Some announcement"
|
||||
|
||||
|
||||
def test_parse_with_image_url():
|
||||
text = "Spring Cup"
|
||||
result = parse_caption(text, image_url="https://example.com/img.jpg")
|
||||
assert result.image_url == "https://example.com/img.jpg"
|
||||
|
||||
|
||||
def test_parse_empty_caption():
|
||||
result = parse_caption("")
|
||||
assert result.title == "Untitled Championship"
|
||||
assert result.description is None
|
||||
Reference in New Issue
Block a user