test: add test suite with 65 tests, 73% coverage

- Unit tests: password hashing, notification providers, webhook field parsing
- Integration tests: auth routes (register/login/confirm-email/logout),
  invite flow, Evotor webhooks (/user/create, /user/verify, /user/token),
  admin panel (access control, activate/suspend/delete/reset-password)
- conftest: SQLite in-memory engine, transactional sessions, factory-boy
  factories (UserFactory with UserRoleEnum variants)
- Fix bcrypt: replace passlib (broken on Python 3.14 + bcrypt 5.x) with
  direct bcrypt calls; drop passlib from requirements.txt
- Fix datetime.utcnow() deprecation across routes and tests
- Fix Jinja2 TemplateResponse signature (request as first positional arg)
- Add coverage config to pyproject.toml

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mguschin
2026-04-28 12:27:42 +03:00
parent 5ead89e0cf
commit fc65e591b3
18 changed files with 882 additions and 31 deletions

View File

@@ -1,5 +1,5 @@
import secrets
from datetime import datetime, timedelta
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
@@ -23,7 +23,7 @@ PAGE_SIZE = 25
def _render(request: Request, template: str, ctx: dict) -> HTMLResponse:
ctx["request"] = request
ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID)
return templates.TemplateResponse(template, ctx)
return templates.TemplateResponse(ctx.pop("request"), template, ctx)
def _admin_user(request: Request, db: Session) -> User:
@@ -139,7 +139,7 @@ async def admin_reset_password(user_id: int, request: Request, db: Session = Dep
if user:
token = secrets.token_urlsafe(32)
user.password_reset_token = token
user.password_reset_expires = datetime.utcnow() + timedelta(
user.password_reset_expires = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(
minutes=settings.PASSWORD_RESET_EXPIRE_MINUTES
)
db.commit()
@@ -159,7 +159,7 @@ async def admin_send_invite(user_id: int, request: Request, db: Session = Depend
if user:
token = secrets.token_urlsafe(32)
user.invite_token = token
user.invite_expires = datetime.utcnow() + timedelta(hours=settings.INVITE_EXPIRE_HOURS)
user.invite_expires = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(hours=settings.INVITE_EXPIRE_HOURS)
db.commit()
invite_url = f"{settings.BASE_URL}/invite?token={token}"
html = (

View File

@@ -19,7 +19,7 @@ router = APIRouter()
def _render(request: Request, template: str, ctx: dict) -> HTMLResponse:
ctx["request"] = request
ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID)
return templates.TemplateResponse(template, ctx)
return templates.TemplateResponse(ctx.pop("request"), template, ctx)
@router.get("/register")

View File

@@ -8,7 +8,7 @@ POST /user/token — Evotor sends us its own API token for the user.
import json
import logging
import secrets
from datetime import datetime, timedelta
from datetime import datetime, timezone, timedelta
from typing import Any
from fastapi import APIRouter, Depends, Request
@@ -64,7 +64,7 @@ def _upsert_evotor_connection(
conn = db.query(EvotorConnection).filter(
EvotorConnection.evotor_user_id == evotor_user_id
).first()
now = datetime.utcnow()
now = datetime.now(timezone.utc).replace(tzinfo=None)
if conn:
conn.api_token = api_token
if user_id is not None:
@@ -120,7 +120,7 @@ async def user_create(request: Request, db: Session = Depends(get_db)):
if user is None and phone:
user = db.query(User).filter(User.phone == phone).first()
now = datetime.utcnow()
now = datetime.now(timezone.utc).replace(tzinfo=None)
if user:
# Link Evotor to existing user
@@ -246,7 +246,7 @@ async def user_token(request: Request, db: Session = Depends(get_db)):
conn = db.query(EvotorConnection).filter(
EvotorConnection.evotor_user_id == evotor_user_id
).first()
now = datetime.utcnow()
now = datetime.now(timezone.utc).replace(tzinfo=None)
if conn:
conn.access_token = evotor_token
conn.is_online = True

View File

@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
@@ -17,7 +17,7 @@ router = APIRouter()
def _render(request: Request, template: str, ctx: dict) -> HTMLResponse:
ctx["request"] = request
ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID)
return templates.TemplateResponse(template, ctx)
return templates.TemplateResponse(ctx.pop("request"), template, ctx)
def _bad_token(request: Request) -> HTMLResponse:
@@ -33,7 +33,7 @@ def _bad_token(request: Request) -> HTMLResponse:
async def invite_get(request: Request, db: Session = Depends(get_db)):
token = request.query_params.get("token", "")
user = db.query(User).filter(User.invite_token == token).first()
if not user or not user.invite_expires or user.invite_expires < datetime.utcnow():
if not user or not user.invite_expires or user.invite_expires < datetime.now(timezone.utc).replace(tzinfo=None):
return _bad_token(request)
return _render(request, "invite.html", {"user": None, "invite_user": user, "token": token})
@@ -42,7 +42,7 @@ async def invite_get(request: Request, db: Session = Depends(get_db)):
async def invite_post(request: Request, db: Session = Depends(get_db)):
token = request.query_params.get("token", "")
invite_user = db.query(User).filter(User.invite_token == token).first()
if not invite_user or not invite_user.invite_expires or invite_user.invite_expires < datetime.utcnow():
if not invite_user or not invite_user.invite_expires or invite_user.invite_expires < datetime.now(timezone.utc).replace(tzinfo=None):
return _bad_token(request)
form = await request.form()

View File

@@ -16,7 +16,7 @@ router = APIRouter()
def _render(request: Request, template: str, ctx: dict) -> HTMLResponse:
ctx["request"] = request
ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID)
return templates.TemplateResponse(template, ctx)
return templates.TemplateResponse(ctx.pop("request"), template, ctx)
@router.get("/profile")

View File

@@ -1,5 +1,5 @@
import secrets
from datetime import datetime, timedelta
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
@@ -18,7 +18,7 @@ router = APIRouter()
def _render(request: Request, template: str, ctx: dict) -> HTMLResponse:
ctx["request"] = request
ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID)
return templates.TemplateResponse(template, ctx)
return templates.TemplateResponse(ctx.pop("request"), template, ctx)
@router.get("/forgot-password")
@@ -34,7 +34,7 @@ async def forgot_post(request: Request, db: Session = Depends(get_db)):
if user:
token = secrets.token_urlsafe(32)
user.password_reset_token = token
user.password_reset_expires = datetime.utcnow() + timedelta(
user.password_reset_expires = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(
minutes=settings.PASSWORD_RESET_EXPIRE_MINUTES
)
db.commit()
@@ -54,7 +54,7 @@ async def forgot_post(request: Request, db: Session = Depends(get_db)):
async def reset_get(request: Request, db: Session = Depends(get_db)):
token = request.query_params.get("token", "")
user = db.query(User).filter(User.password_reset_token == token).first()
if not user or not user.password_reset_expires or user.password_reset_expires < datetime.utcnow():
if not user or not user.password_reset_expires or user.password_reset_expires < datetime.now(timezone.utc).replace(tzinfo=None):
return _render(request, "message.html", {
"user": None, "title": "Ссылка недействительна",
"message": "Ссылка для сброса пароля устарела или недействительна.",
@@ -72,7 +72,7 @@ async def reset_post(request: Request, db: Session = Depends(get_db)):
errors = []
user = db.query(User).filter(User.password_reset_token == token).first()
if not user or not user.password_reset_expires or user.password_reset_expires < datetime.utcnow():
if not user or not user.password_reset_expires or user.password_reset_expires < datetime.now(timezone.utc).replace(tzinfo=None):
return _render(request, "message.html", {
"user": None, "title": "Ссылка недействительна",
"message": "Ссылка для сброса пароля устарела.",