Back: Add user entity
This commit is contained in:
parent
dd70c7b89c
commit
c5350120e6
13 changed files with 444 additions and 4 deletions
|
|
@ -71,11 +71,11 @@ docker compose exec fastapi-app mypy app/
|
||||||
- `Approach` - single set with weight and reps
|
- `Approach` - single set with weight and reps
|
||||||
|
|
||||||
**API Structure** (backend, JSON only):
|
**API Structure** (backend, JSON only):
|
||||||
- `/api/v1/trainings/`, `/api/v1/exercises/`, `/api/v1/exercises/progress/?name=`, `/api/v1/parser/parse/`, `/api/v1/health/`
|
- `/api/v1/trainings/` (GET list, GET/PUT/DELETE `{id}/`; PUT replaces the whole training), `/api/v1/exercises/`, `/api/v1/exercises/progress/?name=`, `/api/v1/users/` (POST, GET list with `?role=`, GET/PATCH/DELETE `{id}/`; role is `trainer` or `trainee`, passwords are hashed with scrypt, no login yet), `/api/v1/parser/parse/`, `/api/v1/health/`
|
||||||
|
|
||||||
**Frontend** (`frontend/`):
|
**Frontend** (`frontend/`):
|
||||||
- `index.html`, `exercises.html`, `parse.html` - pages; `js/common.js` (API + DOM helpers), `js/table.js` (search/sort/paging), one module per page
|
- `index.html`, `exercises.html`, `parse.html` - pages; `js/common.js` (API + DOM helpers), `js/table.js` (DataTables 3 wrapper), one module per page, `js/edit-dialog.js` + `js/edit-form.js` (training edit Bootstrap modal)
|
||||||
- `vendor/` - Bootstrap CSS and Chart.js (the only third-party code)
|
- `vendor/` - Bootstrap (CSS + JS bundle), Chart.js and DataTables 3 (jQuery-free): the only third-party code
|
||||||
- `nginx.conf` - serves static files and proxies `/api/` to `fastapi-app:8000` (same origin, no CORS)
|
- `nginx.conf` - serves static files and proxies `/api/` to `fastapi-app:8000` (same origin, no CORS)
|
||||||
|
|
||||||
### Database
|
### Database
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.database.connection import get_session
|
from app.core.database.connection import get_session
|
||||||
from app.core.database.repository import TrainingRepository
|
from app.core.database.repository import TrainingRepository
|
||||||
|
from app.core.database.user_repository import UserRepository
|
||||||
|
|
||||||
|
|
||||||
def get_training_repository(
|
def get_training_repository(
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> TrainingRepository:
|
) -> TrainingRepository:
|
||||||
return TrainingRepository(session)
|
return TrainingRepository(session)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_repository(
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> UserRepository:
|
||||||
|
return UserRepository(session)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.v1 import exercises, parser, health, trainings
|
from app.api.v1 import exercises, parser, health, trainings, users
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -7,3 +7,4 @@ api_router.include_router(health.router, prefix="/health", tags=["health"])
|
||||||
api_router.include_router(parser.router, prefix="/parser", tags=["parser"])
|
api_router.include_router(parser.router, prefix="/parser", tags=["parser"])
|
||||||
api_router.include_router(trainings.router, prefix="/trainings", tags=["trainings"])
|
api_router.include_router(trainings.router, prefix="/trainings", tags=["trainings"])
|
||||||
api_router.include_router(exercises.router, prefix="/exercises", tags=["exercises"])
|
api_router.include_router(exercises.router, prefix="/exercises", tags=["exercises"])
|
||||||
|
api_router.include_router(users.router, prefix="/users", tags=["users"])
|
||||||
|
|
|
||||||
80
backend/app/api/v1/users.py
Normal file
80
backend/app/api/v1/users.py
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from app.api.deps import get_user_repository
|
||||||
|
from app.core.database.user_repository import EmailTakenError, UserRepository
|
||||||
|
from app.core.dto.user import UserDTO, UserRole
|
||||||
|
from app.core.security import hash_password
|
||||||
|
from app.schemas.users import UserCreate, UserUpdate
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
EMAIL_TAKEN = HTTPException(status_code=409, detail="Email already registered")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=UserDTO, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_user(
|
||||||
|
payload: UserCreate,
|
||||||
|
repository: UserRepository = Depends(get_user_repository),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await repository.create(
|
||||||
|
email=payload.email,
|
||||||
|
password_hash=hash_password(payload.password),
|
||||||
|
first_name=payload.first_name,
|
||||||
|
last_name=payload.last_name,
|
||||||
|
role=payload.role,
|
||||||
|
)
|
||||||
|
except EmailTakenError:
|
||||||
|
raise EMAIL_TAKEN
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[UserDTO])
|
||||||
|
async def list_users(
|
||||||
|
role: Optional[UserRole] = None,
|
||||||
|
repository: UserRepository = Depends(get_user_repository),
|
||||||
|
):
|
||||||
|
return await repository.list(role)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}/", response_model=UserDTO)
|
||||||
|
async def get_user(
|
||||||
|
user_id: int,
|
||||||
|
repository: UserRepository = Depends(get_user_repository),
|
||||||
|
):
|
||||||
|
user = await repository.get(user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{user_id}/", response_model=UserDTO)
|
||||||
|
async def update_user(
|
||||||
|
user_id: int,
|
||||||
|
payload: UserUpdate,
|
||||||
|
repository: UserRepository = Depends(get_user_repository),
|
||||||
|
):
|
||||||
|
values = payload.model_dump(exclude_unset=True)
|
||||||
|
if "password" in values:
|
||||||
|
values["password_hash"] = hash_password(values.pop("password"))
|
||||||
|
# null makes no sense for these columns
|
||||||
|
if any(values.get(name, "") is None for name in ("email", "role", "is_active", "password_hash")):
|
||||||
|
raise HTTPException(status_code=422, detail="Field must not be null")
|
||||||
|
try:
|
||||||
|
user = await repository.update(user_id, values)
|
||||||
|
except EmailTakenError:
|
||||||
|
raise EMAIL_TAKEN
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{user_id}/")
|
||||||
|
async def delete_user(
|
||||||
|
user_id: int,
|
||||||
|
repository: UserRepository = Depends(get_user_repository),
|
||||||
|
):
|
||||||
|
if not await repository.delete(user_id):
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
@ -3,6 +3,7 @@ from sqlalchemy import (
|
||||||
Column,
|
Column,
|
||||||
Date,
|
Date,
|
||||||
DateTime,
|
DateTime,
|
||||||
|
Enum,
|
||||||
Float,
|
Float,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Integer,
|
Integer,
|
||||||
|
|
@ -12,6 +13,8 @@ from sqlalchemy import (
|
||||||
func,
|
func,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from app.core.dto.user import UserRole
|
||||||
|
|
||||||
metadata = MetaData()
|
metadata = MetaData()
|
||||||
|
|
||||||
trainings = Table(
|
trainings = Table(
|
||||||
|
|
@ -52,3 +55,31 @@ approaches = Table(
|
||||||
Column("reps", Integer, nullable=False),
|
Column("reps", Integer, nullable=False),
|
||||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
users = Table(
|
||||||
|
"users",
|
||||||
|
metadata,
|
||||||
|
Column("id", Integer, primary_key=True),
|
||||||
|
Column("email", String(254), nullable=False, unique=True, index=True),
|
||||||
|
Column("password_hash", String(255), nullable=False),
|
||||||
|
Column("first_name", String(100), nullable=False, server_default=""),
|
||||||
|
Column("last_name", String(100), nullable=False, server_default=""),
|
||||||
|
# VARCHAR + CHECK instead of a native PostgreSQL enum: adding a role is a plain migration
|
||||||
|
Column(
|
||||||
|
"role",
|
||||||
|
Enum(
|
||||||
|
UserRole,
|
||||||
|
name="user_role",
|
||||||
|
native_enum=False,
|
||||||
|
length=20,
|
||||||
|
create_constraint=True,
|
||||||
|
values_callable=lambda roles: [role.value for role in roles],
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
server_default=UserRole.trainee.value,
|
||||||
|
),
|
||||||
|
Column("is_active", Boolean, nullable=False, server_default="true"),
|
||||||
|
Column("is_superuser", Boolean, nullable=False, server_default="false"),
|
||||||
|
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||||
|
Column("last_login", DateTime(timezone=True)),
|
||||||
|
)
|
||||||
|
|
|
||||||
81
backend/app/core/database/user_repository.py
Normal file
81
backend/app/core/database/user_repository.py
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import delete, insert, select, update
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database.tables import users
|
||||||
|
from app.core.dto.user import UserDTO, UserRole
|
||||||
|
|
||||||
|
|
||||||
|
class EmailTakenError(Exception):
|
||||||
|
"""A user with this email already exists."""
|
||||||
|
|
||||||
|
|
||||||
|
class UserRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
email: str,
|
||||||
|
password_hash: str,
|
||||||
|
first_name: str = "",
|
||||||
|
last_name: str = "",
|
||||||
|
role: UserRole = UserRole.trainee,
|
||||||
|
) -> UserDTO:
|
||||||
|
query = (
|
||||||
|
insert(users)
|
||||||
|
.values(
|
||||||
|
email=email,
|
||||||
|
password_hash=password_hash,
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
role=role,
|
||||||
|
)
|
||||||
|
.returning(users)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
row = (await self._session.execute(query)).one()
|
||||||
|
await self._session.commit()
|
||||||
|
except IntegrityError as error:
|
||||||
|
await self._session.rollback()
|
||||||
|
raise EmailTakenError(email) from error
|
||||||
|
return UserDTO.model_validate(row)
|
||||||
|
|
||||||
|
async def get(self, user_id: int) -> Optional[UserDTO]:
|
||||||
|
row = (
|
||||||
|
await self._session.execute(select(users).where(users.c.id == user_id))
|
||||||
|
).first()
|
||||||
|
return UserDTO.model_validate(row) if row else None
|
||||||
|
|
||||||
|
async def list(self, role: Optional[UserRole] = None) -> List[UserDTO]:
|
||||||
|
query = select(users).order_by(users.c.id)
|
||||||
|
if role is not None:
|
||||||
|
query = query.where(users.c.role == role)
|
||||||
|
return [UserDTO.model_validate(row) for row in await self._session.execute(query)]
|
||||||
|
|
||||||
|
async def update(self, user_id: int, values: Dict[str, Any]) -> Optional[UserDTO]:
|
||||||
|
"""Change the given columns, None if there is no such user."""
|
||||||
|
if not values:
|
||||||
|
return await self.get(user_id)
|
||||||
|
try:
|
||||||
|
row = (
|
||||||
|
await self._session.execute(
|
||||||
|
update(users)
|
||||||
|
.where(users.c.id == user_id)
|
||||||
|
.values(**values)
|
||||||
|
.returning(users)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
await self._session.commit()
|
||||||
|
except IntegrityError as error:
|
||||||
|
await self._session.rollback()
|
||||||
|
raise EmailTakenError(values.get("email")) from error
|
||||||
|
return UserDTO.model_validate(row) if row else None
|
||||||
|
|
||||||
|
async def delete(self, user_id: int) -> bool:
|
||||||
|
result = await self._session.execute(delete(users).where(users.c.id == user_id))
|
||||||
|
await self._session.commit()
|
||||||
|
return bool(result.rowcount) # type: ignore[attr-defined]
|
||||||
25
backend/app/core/dto/user.py
Normal file
25
backend/app/core/dto/user.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(str, Enum):
|
||||||
|
trainer = "trainer"
|
||||||
|
trainee = "trainee"
|
||||||
|
|
||||||
|
|
||||||
|
class UserDTO(BaseModel):
|
||||||
|
"""A user without the password hash: safe to return from the API."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
email: str
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
role: UserRole
|
||||||
|
is_active: bool
|
||||||
|
is_superuser: bool
|
||||||
|
created_at: datetime
|
||||||
|
last_login: datetime | None = None
|
||||||
39
backend/app/core/security.py
Normal file
39
backend/app/core/security.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
"""Password hashing with scrypt from the standard library (no extra dependencies).
|
||||||
|
|
||||||
|
Stored format: scrypt$<n>$<r>$<p>$<salt, base64>$<hash, base64>. The parameters are stored with
|
||||||
|
the hash, so they can be raised later without invalidating existing passwords.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
_N, _R, _P = 2**14, 8, 1
|
||||||
|
_KEY_LENGTH = 32
|
||||||
|
|
||||||
|
|
||||||
|
def _derive(password: str, salt: bytes, n: int, r: int, p: int) -> bytes:
|
||||||
|
return hashlib.scrypt(
|
||||||
|
password.encode(), salt=salt, n=n, r=r, p=p, dklen=_KEY_LENGTH,
|
||||||
|
maxmem=128 * n * r * 2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
salt = secrets.token_bytes(16)
|
||||||
|
key = _derive(password, salt, _N, _R, _P)
|
||||||
|
encode = lambda value: base64.b64encode(value).decode() # noqa: E731
|
||||||
|
return f"scrypt${_N}${_R}${_P}${encode(salt)}${encode(key)}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, stored: str) -> bool:
|
||||||
|
try:
|
||||||
|
scheme, n, r, p, salt, key = stored.split("$")
|
||||||
|
if scheme != "scrypt":
|
||||||
|
return False
|
||||||
|
expected = base64.b64decode(key)
|
||||||
|
actual = _derive(password, base64.b64decode(salt), int(n), int(r), int(p))
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return hmac.compare_digest(actual, expected)
|
||||||
41
backend/app/schemas/users.py
Normal file
41
backend/app/schemas/users.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import re
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
from app.core.dto.user import UserRole
|
||||||
|
|
||||||
|
EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_email(value: str) -> str:
|
||||||
|
value = value.strip().lower()
|
||||||
|
if not EMAIL_PATTERN.match(value) or len(value) > 254:
|
||||||
|
raise ValueError("invalid email")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
email: str
|
||||||
|
password: str = Field(min_length=8, max_length=128)
|
||||||
|
first_name: str = Field(default="", max_length=100)
|
||||||
|
last_name: str = Field(default="", max_length=100)
|
||||||
|
role: UserRole = UserRole.trainee
|
||||||
|
|
||||||
|
_email = field_validator("email")(_normalize_email)
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
"""Partial update: only the fields that were sent are changed."""
|
||||||
|
|
||||||
|
email: Optional[str] = None
|
||||||
|
password: Optional[str] = Field(default=None, min_length=8, max_length=128)
|
||||||
|
first_name: Optional[str] = Field(default=None, max_length=100)
|
||||||
|
last_name: Optional[str] = Field(default=None, max_length=100)
|
||||||
|
role: Optional[UserRole] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
@field_validator("email")
|
||||||
|
@classmethod
|
||||||
|
def normalize_email(cls, value: Optional[str]) -> Optional[str]:
|
||||||
|
return None if value is None else _normalize_email(value)
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
"""add users
|
||||||
|
|
||||||
|
Revision ID: 97535a10551d
|
||||||
|
Revises: 08ba8bee6328
|
||||||
|
Create Date: 2026-10-23 17:27:16.608304
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '97535a10551d'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '08ba8bee6328'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('users',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('email', sa.String(length=254), nullable=False),
|
||||||
|
sa.Column('password_hash', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('first_name', sa.String(length=100), server_default='', nullable=False),
|
||||||
|
sa.Column('last_name', sa.String(length=100), server_default='', nullable=False),
|
||||||
|
sa.Column('role', sa.Enum('trainer', 'trainee', name='user_role', native_enum=False, create_constraint=True, length=20), server_default='trainee', nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
|
||||||
|
sa.Column('is_superuser', sa.Boolean(), server_default='false', nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('last_login', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_users_email'), table_name='users')
|
||||||
|
op.drop_table('users')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
@ -63,5 +63,7 @@ async def session(alembic_config):
|
||||||
engine = create_engine(TEST_DATABASE_URL)
|
engine = create_engine(TEST_DATABASE_URL)
|
||||||
async with create_session_factory(engine)() as session:
|
async with create_session_factory(engine)() as session:
|
||||||
await TrainingRepository(session).delete_all()
|
await TrainingRepository(session).delete_all()
|
||||||
|
await session.execute(text("TRUNCATE users RESTART IDENTITY"))
|
||||||
|
await session.commit()
|
||||||
yield session
|
yield session
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
|
||||||
16
backend/tests/test_core/test_security.py
Normal file
16
backend/tests/test_core/test_security.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
from app.core.security import hash_password, verify_password
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_verifies_only_the_right_password():
|
||||||
|
stored = hash_password("correct horse")
|
||||||
|
assert verify_password("correct horse", stored)
|
||||||
|
assert not verify_password("wrong", stored)
|
||||||
|
|
||||||
|
|
||||||
|
def test_hashes_are_salted():
|
||||||
|
assert hash_password("same") != hash_password("same")
|
||||||
|
|
||||||
|
|
||||||
|
def test_garbage_hash_does_not_verify():
|
||||||
|
assert not verify_password("x", "not-a-hash")
|
||||||
|
assert not verify_password("x", "bcrypt$1$2$3$4$5")
|
||||||
71
backend/tests/test_core/test_users.py
Normal file
71
backend/tests/test_core/test_users.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.core.database.user_repository import EmailTakenError, UserRepository
|
||||||
|
from app.core.dto.user import UserRole
|
||||||
|
from app.schemas.users import UserCreate, UserUpdate
|
||||||
|
|
||||||
|
|
||||||
|
def test_email_is_normalized():
|
||||||
|
user = UserCreate(email=" Ivan@Example.COM ", password="12345678")
|
||||||
|
assert user.email == "ivan@example.com"
|
||||||
|
assert user.role == UserRole.trainee
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("email", ["", "no-at.example.com", "a@b", "a b@c.d"])
|
||||||
|
def test_invalid_email_is_rejected(email):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
UserCreate(email=email, password="12345678")
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
UserUpdate(email=email)
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_password_and_unknown_role_are_rejected():
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
UserCreate(email="a@b.co", password="short")
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
UserCreate(email="a@b.co", password="12345678", role="admin")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_get_list_and_filter_by_role(session):
|
||||||
|
repository = UserRepository(session)
|
||||||
|
trainer = await repository.create(
|
||||||
|
email="coach@example.com", password_hash="h", role=UserRole.trainer
|
||||||
|
)
|
||||||
|
trainee = await repository.create(email="athlete@example.com", password_hash="h")
|
||||||
|
|
||||||
|
assert trainer.role == UserRole.trainer and trainee.role == UserRole.trainee
|
||||||
|
assert trainee.is_active and not trainee.is_superuser and trainee.last_login is None
|
||||||
|
assert (await repository.get(trainer.id)).email == "coach@example.com"
|
||||||
|
assert await repository.get(trainee.id + 100) is None
|
||||||
|
assert [u.id for u in await repository.list()] == [trainer.id, trainee.id]
|
||||||
|
assert [u.id for u in await repository.list(UserRole.trainer)] == [trainer.id]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_duplicate_email_is_rejected_and_session_stays_usable(session):
|
||||||
|
repository = UserRepository(session)
|
||||||
|
await repository.create(email="a@example.com", password_hash="h")
|
||||||
|
|
||||||
|
with pytest.raises(EmailTakenError):
|
||||||
|
await repository.create(email="a@example.com", password_hash="h")
|
||||||
|
assert len(await repository.list()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_and_delete(session):
|
||||||
|
repository = UserRepository(session)
|
||||||
|
user = await repository.create(email="a@example.com", password_hash="h")
|
||||||
|
other = await repository.create(email="b@example.com", password_hash="h")
|
||||||
|
|
||||||
|
updated = await repository.update(
|
||||||
|
user.id, {"first_name": "Ivan", "role": UserRole.trainer, "is_active": False}
|
||||||
|
)
|
||||||
|
assert (updated.first_name, updated.role, updated.is_active) == (
|
||||||
|
"Ivan", UserRole.trainer, False,
|
||||||
|
)
|
||||||
|
assert (await repository.update(user.id, {})).first_name == "Ivan"
|
||||||
|
assert await repository.update(user.id + 100, {"first_name": "x"}) is None
|
||||||
|
with pytest.raises(EmailTakenError):
|
||||||
|
await repository.update(user.id, {"email": other.email})
|
||||||
|
|
||||||
|
assert await repository.delete(user.id) is True
|
||||||
|
assert await repository.delete(user.id) is False
|
||||||
Loading…
Reference in a new issue