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:
6
backend/.gitignore
vendored
Normal file
6
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.db
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
36
backend/alembic.ini
Normal file
36
backend/alembic.ini
Normal 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
50
backend/alembic/env.py
Normal 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()
|
||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal 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"}
|
||||
@@ -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
0
backend/app/__init__.py
Normal file
0
backend/app/auth/__init__.py
Normal file
0
backend/app/auth/__init__.py
Normal file
58
backend/app/auth/router.py
Normal file
58
backend/app/auth/router.py
Normal 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,
|
||||
)
|
||||
28
backend/app/auth/schemas.py
Normal file
28
backend/app/auth/schemas.py
Normal 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
|
||||
62
backend/app/auth/service.py
Normal file
62
backend/app/auth/service.py
Normal 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
15
backend/app/config.py
Normal 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()
|
||||
0
backend/app/db/__init__.py
Normal file
0
backend/app/db/__init__.py
Normal file
32
backend/app/db/base.py
Normal file
32
backend/app/db/base.py
Normal 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(),
|
||||
)
|
||||
4
backend/app/db/models/__init__.py
Normal file
4
backend/app/db/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from app.db.models.tenant import Tenant
|
||||
from app.db.models.user import User
|
||||
|
||||
__all__ = ["Tenant", "User"]
|
||||
18
backend/app/db/models/tenant.py
Normal file
18
backend/app/db/models/tenant.py
Normal 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)
|
||||
13
backend/app/db/models/user.py
Normal file
13
backend/app/db/models/user.py
Normal 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
11
backend/app/db/session.py
Normal 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
22
backend/app/deps.py
Normal 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
35
backend/app/main.py
Normal 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
2
backend/pytest.ini
Normal file
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
14
backend/requirements.txt
Normal file
14
backend/requirements.txt
Normal 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
|
||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
24
backend/tests/conftest.py
Normal file
24
backend/tests/conftest.py
Normal 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
105
backend/tests/test_auth.py
Normal 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"}
|
||||
Reference in New Issue
Block a user