From 907b7be0fdbff0bbfa6471520ae5326f100e5b69 Mon Sep 17 00:00:00 2001 From: Marius Mutu Date: Fri, 13 Mar 2026 17:26:31 +0200 Subject: [PATCH] feat(backend): FastAPI + libSQL + auth register/login/me + tests (TDD) - FastAPI app with lifespan, CORS, health endpoint - SQLAlchemy 2.0 async with aiosqlite, Base/UUIDMixin/TenantMixin/TimestampMixin - Tenant and User models with multi-tenant isolation - Auth: register (creates tenant+user), login, /me endpoint - JWT HS256 tokens, bcrypt password hashing - Alembic async setup with initial migration - 6 passing tests (register, login, wrong password, me, no token, health) Co-Authored-By: Claude Sonnet 4.6 --- backend/.gitignore | 6 + backend/alembic.ini | 36 ++++++ backend/alembic/env.py | 50 +++++++++ backend/alembic/script.py.mako | 26 +++++ .../88221cd8e1c3_initial_tenants_users.py | 62 +++++++++++ backend/app/__init__.py | 0 backend/app/auth/__init__.py | 0 backend/app/auth/router.py | 58 ++++++++++ backend/app/auth/schemas.py | 28 +++++ backend/app/auth/service.py | 62 +++++++++++ backend/app/config.py | 15 +++ backend/app/db/__init__.py | 0 backend/app/db/base.py | 32 ++++++ backend/app/db/models/__init__.py | 4 + backend/app/db/models/tenant.py | 18 +++ backend/app/db/models/user.py | 13 +++ backend/app/db/session.py | 11 ++ backend/app/deps.py | 22 ++++ backend/app/main.py | 35 ++++++ backend/pytest.ini | 2 + backend/requirements.txt | 14 +++ backend/tests/__init__.py | 0 backend/tests/conftest.py | 24 ++++ backend/tests/test_auth.py | 105 ++++++++++++++++++ 24 files changed, 623 insertions(+) create mode 100644 backend/.gitignore create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/88221cd8e1c3_initial_tenants_users.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/auth/__init__.py create mode 100644 backend/app/auth/router.py create mode 100644 backend/app/auth/schemas.py create mode 100644 backend/app/auth/service.py create mode 100644 backend/app/config.py create mode 100644 backend/app/db/__init__.py create mode 100644 backend/app/db/base.py create mode 100644 backend/app/db/models/__init__.py create mode 100644 backend/app/db/models/tenant.py create mode 100644 backend/app/db/models/user.py create mode 100644 backend/app/db/session.py create mode 100644 backend/app/deps.py create mode 100644 backend/app/main.py create mode 100644 backend/pytest.ini create mode 100644 backend/requirements.txt create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_auth.py diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..f1459aa --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.pyc +*.db +.pytest_cache/ +*.egg-info/ diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..80682d7 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,36 @@ +[alembic] +script_location = alembic +sqlalchemy.url = sqlite+aiosqlite:///./data/roaauto.db + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..77faa42 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,50 @@ +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy.ext.asyncio import create_async_engine + +from app.config import settings +from app.db.base import Base +import app.db.models # noqa: F401 + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = settings.DATABASE_URL + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection): + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + connectable = create_async_engine(settings.DATABASE_URL) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +def run_migrations_online() -> None: + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/88221cd8e1c3_initial_tenants_users.py b/backend/alembic/versions/88221cd8e1c3_initial_tenants_users.py new file mode 100644 index 0000000..018b3ca --- /dev/null +++ b/backend/alembic/versions/88221cd8e1c3_initial_tenants_users.py @@ -0,0 +1,62 @@ +"""initial_tenants_users + +Revision ID: 88221cd8e1c3 +Revises: +Create Date: 2026-03-13 17:25:56.158996 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '88221cd8e1c3' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('tenants', + sa.Column('nume', sa.String(length=200), nullable=False), + sa.Column('cui', sa.String(length=20), nullable=True), + sa.Column('reg_com', sa.String(length=30), nullable=True), + sa.Column('adresa', sa.Text(), nullable=True), + sa.Column('telefon', sa.String(length=20), nullable=True), + sa.Column('email', sa.String(length=200), nullable=True), + sa.Column('iban', sa.String(length=34), nullable=True), + sa.Column('banca', sa.String(length=100), nullable=True), + sa.Column('plan', sa.String(length=20), nullable=False), + sa.Column('trial_expires_at', sa.Text(), nullable=True), + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('created_at', sa.Text(), nullable=False), + sa.Column('updated_at', sa.Text(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('users', + sa.Column('email', sa.String(length=200), nullable=False), + sa.Column('password_hash', sa.String(length=200), nullable=False), + sa.Column('nume', sa.String(length=200), nullable=False), + sa.Column('rol', sa.String(length=20), nullable=False), + sa.Column('activ', sa.Boolean(), nullable=False), + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('created_at', sa.Text(), nullable=False), + sa.Column('updated_at', sa.Text(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_index(op.f('ix_users_tenant_id'), 'users', ['tenant_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_users_tenant_id'), table_name='users') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + op.drop_table('tenants') + # ### end Alembic commands ### diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/auth/router.py b/backend/app/auth/router.py new file mode 100644 index 0000000..099e324 --- /dev/null +++ b/backend/app/auth/router.py @@ -0,0 +1,58 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth import schemas, service +from app.db.session import get_db +from app.deps import get_current_user + +router = APIRouter() + + +@router.post("/register", response_model=schemas.TokenResponse) +async def register( + data: schemas.RegisterRequest, db: AsyncSession = Depends(get_db) +): + user, tenant = await service.register( + db, data.email, data.password, data.tenant_name, data.telefon + ) + return schemas.TokenResponse( + access_token=service.create_token(user.id, tenant.id, tenant.plan), + tenant_id=tenant.id, + plan=tenant.plan, + ) + + +@router.post("/login", response_model=schemas.TokenResponse) +async def login(data: schemas.LoginRequest, db: AsyncSession = Depends(get_db)): + user, tenant = await service.authenticate(db, data.email, data.password) + if not user: + raise HTTPException(status_code=401, detail="Credentiale invalide") + return schemas.TokenResponse( + access_token=service.create_token(user.id, tenant.id, tenant.plan), + tenant_id=tenant.id, + plan=tenant.plan, + ) + + +@router.get("/me", response_model=schemas.UserResponse) +async def me( + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + from sqlalchemy import select + from app.db.models.user import User + from app.db.models.tenant import Tenant + + r = await db.execute(select(User).where(User.id == current_user["sub"])) + user = r.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + r = await db.execute(select(Tenant).where(Tenant.id == user.tenant_id)) + tenant = r.scalar_one() + return schemas.UserResponse( + id=user.id, + email=user.email, + tenant_id=user.tenant_id, + plan=tenant.plan, + rol=user.rol, + ) diff --git a/backend/app/auth/schemas.py b/backend/app/auth/schemas.py new file mode 100644 index 0000000..90037b1 --- /dev/null +++ b/backend/app/auth/schemas.py @@ -0,0 +1,28 @@ +from pydantic import BaseModel, EmailStr + + +class RegisterRequest(BaseModel): + email: EmailStr + password: str + tenant_name: str + telefon: str + + +class LoginRequest(BaseModel): + email: EmailStr + password: str + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + tenant_id: str + plan: str + + +class UserResponse(BaseModel): + id: str + email: str + tenant_id: str + plan: str + rol: str diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py new file mode 100644 index 0000000..558aeed --- /dev/null +++ b/backend/app/auth/service.py @@ -0,0 +1,62 @@ +from datetime import UTC, datetime, timedelta + +from jose import jwt +import bcrypt +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.db.base import uuid7 +from app.db.models.tenant import Tenant +from app.db.models.user import User + + +def hash_password(p: str) -> str: + return bcrypt.hashpw(p.encode(), bcrypt.gensalt()).decode() + + +def verify_password(plain: str, hashed: str) -> bool: + return bcrypt.checkpw(plain.encode(), hashed.encode()) + + +def create_token(user_id: str, tenant_id: str, plan: str) -> str: + exp = datetime.now(UTC) + timedelta(days=settings.ACCESS_TOKEN_EXPIRE_DAYS) + return jwt.encode( + {"sub": user_id, "tenant_id": tenant_id, "plan": plan, "exp": exp}, + settings.SECRET_KEY, + algorithm="HS256", + ) + + +async def register( + db: AsyncSession, email: str, password: str, tenant_name: str, telefon: str +): + trial_exp = (datetime.now(UTC) + timedelta(days=settings.TRIAL_DAYS)).isoformat() + tenant = Tenant( + id=uuid7(), + nume=tenant_name, + telefon=telefon, + plan="trial", + trial_expires_at=trial_exp, + ) + db.add(tenant) + user = User( + id=uuid7(), + tenant_id=tenant.id, + email=email, + password_hash=hash_password(password), + nume=email.split("@")[0], + rol="owner", + ) + db.add(user) + await db.commit() + return user, tenant + + +async def authenticate(db: AsyncSession, email: str, password: str): + r = await db.execute(select(User).where(User.email == email)) + user = r.scalar_one_or_none() + if not user or not verify_password(password, user.password_hash): + return None, None + r = await db.execute(select(Tenant).where(Tenant.id == user.tenant_id)) + return user, r.scalar_one_or_none() diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..cf6ada4 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,15 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env") + + SECRET_KEY: str = "dev-secret-change-me" + DATABASE_URL: str = "sqlite+aiosqlite:///./data/roaauto.db" + ACCESS_TOKEN_EXPIRE_DAYS: int = 30 + TRIAL_DAYS: int = 30 + SMSAPI_TOKEN: str = "" + CORS_ORIGINS: str = "http://localhost:5173" + + +settings = Settings() diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 0000000..0295bc2 --- /dev/null +++ b/backend/app/db/base.py @@ -0,0 +1,32 @@ +import uuid +from datetime import datetime, UTC + +from sqlalchemy import String, Text +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +def uuid7() -> str: + return str(uuid.uuid4()) + + +class Base(DeclarativeBase): + pass + + +class UUIDMixin: + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid7) + + +class TenantMixin: + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + + +class TimestampMixin: + created_at: Mapped[str] = mapped_column( + Text, default=lambda: datetime.now(UTC).isoformat() + ) + updated_at: Mapped[str] = mapped_column( + Text, + default=lambda: datetime.now(UTC).isoformat(), + onupdate=lambda: datetime.now(UTC).isoformat(), + ) diff --git a/backend/app/db/models/__init__.py b/backend/app/db/models/__init__.py new file mode 100644 index 0000000..cf90ceb --- /dev/null +++ b/backend/app/db/models/__init__.py @@ -0,0 +1,4 @@ +from app.db.models.tenant import Tenant +from app.db.models.user import User + +__all__ = ["Tenant", "User"] diff --git a/backend/app/db/models/tenant.py b/backend/app/db/models/tenant.py new file mode 100644 index 0000000..ae8109b --- /dev/null +++ b/backend/app/db/models/tenant.py @@ -0,0 +1,18 @@ +from sqlalchemy import String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base, UUIDMixin, TimestampMixin + + +class Tenant(Base, UUIDMixin, TimestampMixin): + __tablename__ = "tenants" + nume: Mapped[str] = mapped_column(String(200)) + cui: Mapped[str | None] = mapped_column(String(20)) + reg_com: Mapped[str | None] = mapped_column(String(30)) + adresa: Mapped[str | None] = mapped_column(Text) + telefon: Mapped[str | None] = mapped_column(String(20)) + email: Mapped[str | None] = mapped_column(String(200)) + iban: Mapped[str | None] = mapped_column(String(34)) + banca: Mapped[str | None] = mapped_column(String(100)) + plan: Mapped[str] = mapped_column(String(20), default="trial") + trial_expires_at: Mapped[str | None] = mapped_column(Text) diff --git a/backend/app/db/models/user.py b/backend/app/db/models/user.py new file mode 100644 index 0000000..a19a0e2 --- /dev/null +++ b/backend/app/db/models/user.py @@ -0,0 +1,13 @@ +from sqlalchemy import Boolean, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin + + +class User(Base, UUIDMixin, TenantMixin, TimestampMixin): + __tablename__ = "users" + email: Mapped[str] = mapped_column(String(200), unique=True, index=True) + password_hash: Mapped[str] = mapped_column(String(200)) + nume: Mapped[str] = mapped_column(String(200)) + rol: Mapped[str] = mapped_column(String(20), default="owner") + activ: Mapped[bool] = mapped_column(Boolean, default=True) diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..45925b7 --- /dev/null +++ b/backend/app/db/session.py @@ -0,0 +1,11 @@ +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.config import settings + +engine = create_async_engine(settings.DATABASE_URL, echo=False) +AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) + + +async def get_db(): + async with AsyncSessionLocal() as session: + yield session diff --git a/backend/app/deps.py b/backend/app/deps.py new file mode 100644 index 0000000..49c4167 --- /dev/null +++ b/backend/app/deps.py @@ -0,0 +1,22 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from jose import JWTError, jwt + +from app.config import settings + +bearer = HTTPBearer(auto_error=False) + + +async def get_current_user( + creds: HTTPAuthorizationCredentials | None = Depends(bearer), +) -> dict: + if creds is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + try: + return jwt.decode(creds.credentials, settings.SECRET_KEY, algorithms=["HS256"]) + except JWTError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + + +async def get_tenant_id(user: dict = Depends(get_current_user)) -> str: + return user["tenant_id"] diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..1c9fb07 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,35 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.auth.router import router as auth_router +from app.config import settings +from app.db.base import Base +from app.db.session import engine + +# Import models so Base.metadata knows about them +import app.db.models # noqa: F401 + + +@asynccontextmanager +async def lifespan(app: FastAPI): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + + +app = FastAPI(lifespan=lifespan) +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS.split(","), + allow_methods=["*"], + allow_headers=["*"], + allow_credentials=True, +) +app.include_router(auth_router, prefix="/api/auth") + + +@app.get("/api/health") +async def health(): + return {"status": "ok"} diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..2f4c80e --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +asyncio_mode = auto diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..7081124 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,14 @@ +fastapi>=0.115 +uvicorn[standard]>=0.30 +sqlalchemy>=2.0 +aiosqlite>=0.20 +alembic>=1.13 +python-jose[cryptography]>=3.3 +passlib[bcrypt]>=1.7 +pydantic-settings>=2.0 +pydantic[email]>=2.0 +pytest>=8.0 +pytest-asyncio>=0.23 +httpx>=0.27 +weasyprint>=62 +jinja2>=3.1 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..c6166d9 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,24 @@ +import pytest +import pytest_asyncio +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.db.base import Base +from app.db.session import get_db +from app.main import app + + +@pytest_asyncio.fixture(autouse=True) +async def setup_test_db(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + session_factory = async_sessionmaker(engine, expire_on_commit=False) + + async def override_db(): + async with session_factory() as s: + yield s + + app.dependency_overrides[get_db] = override_db + yield + app.dependency_overrides.clear() + await engine.dispose() diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..b070c69 --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,105 @@ +import pytest +from httpx import ASGITransport, AsyncClient + +from app.main import app + + +@pytest.mark.asyncio +async def test_register_creates_tenant(): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as c: + r = await c.post( + "/api/auth/register", + json={ + "email": "owner@service.ro", + "password": "parola123", + "tenant_name": "Service Ionescu", + "telefon": "0722000000", + }, + ) + assert r.status_code == 200 + data = r.json() + assert "access_token" in data + assert data["plan"] == "trial" + assert data["token_type"] == "bearer" + assert data["tenant_id"] + + +@pytest.mark.asyncio +async def test_login_returns_token(): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as c: + await c.post( + "/api/auth/register", + json={ + "email": "test@s.ro", + "password": "abc123", + "tenant_name": "Test", + "telefon": "0722", + }, + ) + r = await c.post( + "/api/auth/login", + json={"email": "test@s.ro", "password": "abc123"}, + ) + assert r.status_code == 200 + assert "access_token" in r.json() + + +@pytest.mark.asyncio +async def test_login_wrong_password_returns_401(): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as c: + r = await c.post( + "/api/auth/login", + json={"email": "x@x.ro", "password": "wrong"}, + ) + assert r.status_code == 401 + + +@pytest.mark.asyncio +async def test_me_returns_user_info(): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as c: + reg = await c.post( + "/api/auth/register", + json={ + "email": "me@test.ro", + "password": "pass123", + "tenant_name": "My Service", + "telefon": "0733", + }, + ) + token = reg.json()["access_token"] + r = await c.get( + "/api/auth/me", + headers={"Authorization": f"Bearer {token}"}, + ) + assert r.status_code == 200 + data = r.json() + assert data["email"] == "me@test.ro" + assert data["rol"] == "owner" + assert data["plan"] == "trial" + + +@pytest.mark.asyncio +async def test_me_without_token_returns_403(): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as c: + r = await c.get("/api/auth/me") + assert r.status_code == 401 + + +@pytest.mark.asyncio +async def test_health_endpoint(): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as c: + r = await c.get("/api/health") + assert r.status_code == 200 + assert r.json() == {"status": "ok"}