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 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 17:26:31 +02:00
parent c3482bba8d
commit 907b7be0fd
24 changed files with 623 additions and 0 deletions

6
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.venv/
__pycache__/
*.pyc
*.db
.pytest_cache/
*.egg-info/

36
backend/alembic.ini Normal file
View File

@@ -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

50
backend/alembic/env.py Normal file
View File

@@ -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()

View File

@@ -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"}

View File

@@ -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 ###

0
backend/app/__init__.py Normal file
View File

View File

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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()

15
backend/app/config.py Normal file
View File

@@ -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()

View File

32
backend/app/db/base.py Normal file
View File

@@ -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(),
)

View File

@@ -0,0 +1,4 @@
from app.db.models.tenant import Tenant
from app.db.models.user import User
__all__ = ["Tenant", "User"]

View File

@@ -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)

View File

@@ -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)

11
backend/app/db/session.py Normal file
View File

@@ -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

22
backend/app/deps.py Normal file
View File

@@ -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"]

35
backend/app/main.py Normal file
View File

@@ -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"}

2
backend/pytest.ini Normal file
View File

@@ -0,0 +1,2 @@
[pytest]
asyncio_mode = auto

14
backend/requirements.txt Normal file
View File

@@ -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

View File

24
backend/tests/conftest.py Normal file
View File

@@ -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()

105
backend/tests/test_auth.py Normal file
View File

@@ -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"}