Initial commit: RehearsalHub POC

Full-stack self-hosted band rehearsal platform:

Backend (FastAPI + SQLAlchemy 2.0 async):
- Auth with JWT (register, login, /me, settings)
- Band management with Nextcloud folder integration
- Song management with audio version tracking
- Nextcloud scan to auto-import audio files
- Band membership with link-based invite system
- Song comments
- Audio analysis worker (BPM, key, loudness, waveform)
- Nextcloud activity watcher for auto-import
- WebSocket support for real-time annotation updates
- Alembic migrations (0001–0003)
- Repository pattern, Ruff + mypy configured

Frontend (React 18 + Vite + TypeScript strict):
- Login/register page with post-login redirect
- Home page with band list and creation form
- Band page with member panel, invite link, song list, NC scan
- Song page with waveform player, annotations, comment thread
- Settings page for per-user Nextcloud credentials
- Invite acceptance page (/invite/:token)
- ESLint v9 flat config + TypeScript strict mode

Infrastructure:
- Docker Compose: PostgreSQL, Redis, API, worker, watcher, nginx
- nginx reverse proxy for static files + /api/ proxy
- make check runs all linters before docker compose build

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Steffen Schuhmann
2026-03-28 21:53:03 +01:00
commit f7be1b994d
139 changed files with 12743 additions and 0 deletions

View File

@@ -0,0 +1,98 @@
"""Integration test fixtures using testcontainers for a real Postgres."""
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from rehearsalhub.db.models import Base
from rehearsalhub.main import create_app
@pytest.fixture(scope="session")
def pg_container():
"""Start a real Postgres container for the test session."""
from testcontainers.postgres import PostgresContainer
with PostgresContainer("postgres:16-alpine") as pg:
yield pg
@pytest.fixture(scope="session")
def pg_url(pg_container) -> str:
url = pg_container.get_connection_url()
return url.replace("postgresql+psycopg2://", "postgresql+asyncpg://").replace(
"postgresql://", "postgresql+asyncpg://"
)
@pytest_asyncio.fixture(scope="session")
async def pg_engine(pg_url):
engine = create_async_engine(pg_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(scope="function")
async def db_session(pg_engine) -> AsyncSession:
"""Each test gets its own connection with a rolled-back transaction."""
async with pg_engine.connect() as conn:
trans = await conn.begin()
session = AsyncSession(bind=conn, expire_on_commit=False)
yield session
await session.close()
await trans.rollback()
@pytest_asyncio.fixture(scope="function")
async def client(db_session):
"""httpx AsyncClient with DB session dependency overridden."""
from rehearsalhub.db.engine import get_session
app = create_app()
app.dependency_overrides[get_session] = lambda: db_session
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
app.dependency_overrides.clear()
@pytest_asyncio.fixture(scope="function")
async def auth_headers(client, db_session):
"""Register + login a test user, return Authorization headers."""
from tests.factories import create_member
member = await create_member(db_session, email="auth@test.com")
await db_session.commit()
resp = await client.post(
"/api/v1/auth/login", json={"email": "auth@test.com", "password": "testpassword123"}
)
assert resp.status_code == 200, resp.text
token = resp.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
@pytest_asyncio.fixture(scope="function")
async def current_member(db_session):
from tests.factories import create_member
member = await create_member(db_session, email=f"member_{__import__('uuid').uuid4().hex[:6]}@test.com")
await db_session.commit()
return member
@pytest_asyncio.fixture(scope="function")
async def auth_headers_for(client, db_session):
"""Factory fixture: given a member, return auth headers for them."""
async def _make(member):
from rehearsalhub.services.auth import create_access_token
token = create_access_token(str(member.id), member.email)
return {"Authorization": f"Bearer {token}"}
return _make