From cfffc7ff594b63a9e87b59c0a5a28208caf315c9 Mon Sep 17 00:00:00 2001 From: Indiana Date: Mon, 20 Jul 2026 15:14:31 +0000 Subject: [PATCH] feat: add user model and registration endpoint --- backend/app/main.py | 17 ++++++++++++++++- backend/app/models/__init__.py | 3 +++ backend/app/models/user.py | 19 ++++++++++++++++++ backend/app/routes/__init__.py | 0 backend/app/routes/auth.py | 27 ++++++++++++++++++++++++++ backend/app/schemas.py | 17 +++++++++++++++++ backend/app/security.py | 15 +++++++++++++++ backend/tests/conftest.py | 35 ++++++++++++++++++++++++++++------ backend/tests/test_auth.py | 21 ++++++++++++++++++++ 9 files changed, 147 insertions(+), 7 deletions(-) create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/routes/__init__.py create mode 100644 backend/app/routes/auth.py create mode 100644 backend/app/schemas.py create mode 100644 backend/app/security.py create mode 100644 backend/tests/test_auth.py diff --git a/backend/app/main.py b/backend/app/main.py index 4385221..6f06f28 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,6 +1,21 @@ +from contextlib import asynccontextmanager + from fastapi import FastAPI -app = FastAPI(title="Quantumancy") +import app.models # noqa: F401 — registers models on Base.metadata before create_all +from app.db import Base, engine +from app.routes.auth import router as auth_router + + +@asynccontextmanager +async def lifespan(app: FastAPI): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + + +app = FastAPI(title="Quantumancy", lifespan=lifespan) +app.include_router(auth_router) @app.get("/healthz") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..b2e47e8 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,3 @@ +from app.models.user import User + +__all__ = ["User"] diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..ac3f9a3 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,19 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import DateTime, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + username: Mapped[str] = mapped_column(String(32), unique=True, index=True) + password_hash: Mapped[str] = mapped_column(String(255)) + email: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) diff --git a/backend/app/routes/__init__.py b/backend/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py new file mode 100644 index 0000000..c1e8e6a --- /dev/null +++ b/backend/app/routes/auth.py @@ -0,0 +1,27 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import get_db +from app.models.user import User +from app.schemas import RegisterRequest, UserOut +from app.security import hash_password + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED) +async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)): + existing = await db.scalar(select(User).where(User.username == payload.username)) + if existing is not None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="username taken") + + user = User( + username=payload.username, + password_hash=hash_password(payload.password), + email=payload.email, + ) + db.add(user) + await db.commit() + await db.refresh(user) + return user diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..21f9f66 --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,17 @@ +import uuid + +from pydantic import BaseModel, Field + + +class RegisterRequest(BaseModel): + username: str = Field(min_length=3, max_length=32) + password: str = Field(min_length=8, max_length=128) + email: str | None = None + + +class UserOut(BaseModel): + id: uuid.UUID + username: str + + class Config: + from_attributes = True diff --git a/backend/app/security.py b/backend/app/security.py new file mode 100644 index 0000000..a466dc4 --- /dev/null +++ b/backend/app/security.py @@ -0,0 +1,15 @@ +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError + +_hasher = PasswordHasher() + + +def hash_password(password: str) -> str: + return _hasher.hash(password) + + +def verify_password(password: str, password_hash: str) -> bool: + try: + return _hasher.verify(password_hash, password) + except VerifyMismatchError: + return False diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index ae63c4c..f372944 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,8 +1,31 @@ -import os +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import async_sessionmaker + +from app.db import Base, engine, get_db +from app.main import app + +TestSessionLocal = async_sessionmaker(engine, expire_on_commit=False) -def pytest_configure(config): - """Set up minimal environment variables for tests before any imports.""" - os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test") - os.environ.setdefault("OLLAMA_BASE_URL", "http://localhost:11434") - os.environ.setdefault("SESSION_SECRET", "test-secret-key") +@pytest_asyncio.fixture(autouse=True) +async def _reset_db(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + yield + + +async def _override_get_db(): + async with TestSessionLocal() as session: + yield session + + +app.dependency_overrides[get_db] = _override_get_db + + +@pytest_asyncio.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..fbae32f --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,21 @@ +import pytest + + +@pytest.mark.asyncio +async def test_register_creates_user(client): + response = await client.post( + "/auth/register", + json={"username": "medium1", "password": "spookyspooky"}, + ) + assert response.status_code == 201 + body = response.json() + assert body["username"] == "medium1" + assert "id" in body + assert "password" not in body + + +@pytest.mark.asyncio +async def test_register_duplicate_username_rejected(client): + await client.post("/auth/register", json={"username": "medium1", "password": "spookyspooky"}) + response = await client.post("/auth/register", json={"username": "medium1", "password": "anotherpass"}) + assert response.status_code == 409