Skip to content

Commit aaea26a

Browse files
committed
test: расширить unit и smoke покрытие ключевых модулей
- Добавлены тесты для limits, refresh guard, routes health и metrics - Добавлены тесты для database/redis и модуля метрик - Усилены сценарии jwt, password и auth service - Добавлены unit тесты для task и tag сервисов
1 parent 9d3e6dc commit aaea26a

12 files changed

Lines changed: 950 additions & 17 deletions

tests/unit/limits/test_service.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
from __future__ import annotations
2+
3+
from typing import cast
4+
from unittest.mock import AsyncMock
5+
6+
import pytest
7+
from fastapi import HTTPException
8+
from redis.asyncio import Redis
9+
10+
from app.limits.service import enforce_rate_limit
11+
12+
13+
@pytest.fixture
14+
def fake_redis() -> Redis:
15+
"""Создаёт фейковый Redis-клиент для проверки логики rate limit."""
16+
redis = cast(Redis, AsyncMock(spec=Redis))
17+
redis.eval = AsyncMock()
18+
redis.ttl = AsyncMock()
19+
return redis
20+
21+
22+
@pytest.mark.asyncio
23+
async def test_enforce_rate_limit_allows_requests_within_limit(
24+
fake_redis: Redis,
25+
) -> None:
26+
"""Проверяет, что при current <= limit функция не выбрасывает исключение."""
27+
cast(AsyncMock, fake_redis.eval).return_value = 5
28+
29+
await enforce_rate_limit(
30+
fake_redis,
31+
key="rl:test:ip:127.0.0.1",
32+
limit=5,
33+
window_seconds=60,
34+
)
35+
36+
cast(AsyncMock, fake_redis.ttl).assert_not_awaited()
37+
38+
39+
@pytest.mark.asyncio
40+
async def test_enforce_rate_limit_raises_429_with_retry_after(
41+
fake_redis: Redis,
42+
) -> None:
43+
"""Проверяет, что превышение лимита даёт 429 и корректный Retry-After."""
44+
cast(AsyncMock, fake_redis.eval).return_value = 11
45+
cast(AsyncMock, fake_redis.ttl).return_value = 15
46+
47+
with pytest.raises(HTTPException) as exc:
48+
await enforce_rate_limit(
49+
fake_redis,
50+
key="rl:test:ip:127.0.0.1",
51+
limit=10,
52+
window_seconds=60,
53+
)
54+
55+
assert exc.value.status_code == 429
56+
assert exc.value.headers == {"Retry-After": "15"}
57+
assert "15 сек." in exc.value.detail
58+
59+
60+
@pytest.mark.asyncio
61+
async def test_enforce_rate_limit_fallbacks_retry_after_to_one(
62+
fake_redis: Redis,
63+
) -> None:
64+
"""Проверяет fallback Retry-After=1, если Redis вернул TTL <= 0."""
65+
cast(AsyncMock, fake_redis.eval).return_value = 3
66+
cast(AsyncMock, fake_redis.ttl).return_value = 0
67+
68+
with pytest.raises(HTTPException) as exc:
69+
await enforce_rate_limit(
70+
fake_redis,
71+
key="rl:test:ip:127.0.0.1",
72+
limit=2,
73+
window_seconds=60,
74+
)
75+
76+
assert exc.value.status_code == 429
77+
assert exc.value.headers == {"Retry-After": "1"}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from __future__ import annotations
2+
3+
from typing import cast
4+
from unittest.mock import AsyncMock
5+
6+
import pytest
7+
from redis.asyncio import Redis
8+
from sqlalchemy.ext.asyncio import AsyncSession
9+
10+
from app.routes.health import db_health, redis_health
11+
12+
13+
@pytest.fixture
14+
def fake_redis() -> Redis:
15+
"""Создаёт фейковый Redis для smoke-тестов health-роута."""
16+
redis = cast(Redis, AsyncMock(spec=Redis))
17+
redis.ping = AsyncMock()
18+
return redis
19+
20+
21+
@pytest.fixture
22+
def fake_session() -> AsyncSession:
23+
"""Создаёт фейковую AsyncSession для smoke-тестов health-роута."""
24+
session = cast(AsyncSession, AsyncMock(spec=AsyncSession))
25+
session.execute = AsyncMock()
26+
return session
27+
28+
29+
@pytest.mark.asyncio
30+
async def test_redis_health_reports_ok(fake_redis: Redis) -> None:
31+
"""Проверяет, что /health/redis возвращает ok, когда Redis отвечает True."""
32+
cast(AsyncMock, fake_redis.ping).return_value = True
33+
34+
result = await redis_health(redis=fake_redis)
35+
36+
assert result == {"redis": "ok"}
37+
38+
39+
@pytest.mark.asyncio
40+
async def test_redis_health_reports_down(fake_redis: Redis) -> None:
41+
"""Проверяет, что /health/redis возвращает down, когда Redis отвечает False."""
42+
cast(AsyncMock, fake_redis.ping).return_value = False
43+
44+
result = await redis_health(redis=fake_redis)
45+
46+
assert result == {"redis": "down"}
47+
48+
49+
@pytest.mark.asyncio
50+
async def test_db_health_executes_ping_query(fake_session: AsyncSession) -> None:
51+
"""Проверяет, что /health/db выполняет SELECT 1 и возвращает ok."""
52+
result = await db_health(session=fake_session)
53+
54+
assert result == {"db": "ok"}
55+
cast(AsyncMock, fake_session.execute).assert_awaited_once()
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from __future__ import annotations
2+
3+
from unittest.mock import Mock
4+
5+
import pytest
6+
7+
from app.routes import metrics as metrics_route
8+
9+
10+
@pytest.mark.asyncio
11+
async def test_metrics_route_returns_response_from_render_metrics(
12+
monkeypatch: pytest.MonkeyPatch,
13+
) -> None:
14+
"""Проверяет, что /metrics возвращает body и media_type, полученные из render_metrics."""
15+
render_metrics = Mock(
16+
return_value=(b"# test metrics\n", "text/plain; version=0.0.4")
17+
)
18+
monkeypatch.setattr(metrics_route, "render_metrics", render_metrics)
19+
20+
response = await metrics_route.metrics()
21+
22+
assert response.body == b"# test metrics\n"
23+
assert response.media_type == "text/plain; version=0.0.4"
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
from __future__ import annotations
2+
3+
from datetime import UTC, datetime
4+
from types import SimpleNamespace
5+
from typing import Any, cast
6+
from unittest.mock import AsyncMock, Mock
7+
from uuid import uuid4
8+
9+
import pytest
10+
from fastapi import HTTPException
11+
from sqlalchemy.ext.asyncio import AsyncSession
12+
13+
from app.models.user import User
14+
from app.security import dependences as auth_dep
15+
from app.security.jwt import TokenData, TokenInvalid
16+
17+
18+
@pytest.fixture
19+
def fake_session() -> AsyncSession:
20+
"""Создаёт фейковую AsyncSession для unit-тестов зависимости авторизации."""
21+
return cast(AsyncSession, AsyncMock(spec=AsyncSession))
22+
23+
24+
def _make_user(*, is_active: bool = True) -> User:
25+
"""Создаёт лёгкий объект пользователя для тестирования веток get_current_user."""
26+
return cast(
27+
User,
28+
SimpleNamespace(
29+
id=uuid4(),
30+
email="user@example.com",
31+
hashed_password="hashed",
32+
is_active=is_active,
33+
created_at=datetime.now(UTC),
34+
),
35+
)
36+
37+
38+
@pytest.mark.asyncio
39+
async def test_get_current_user_returns_active_user(
40+
fake_session: AsyncSession,
41+
monkeypatch: pytest.MonkeyPatch,
42+
) -> None:
43+
"""Проверяет успешный путь: валидный токен и активный пользователь возвращаются как результат."""
44+
user = _make_user(is_active=True)
45+
decode_access_token = Mock(
46+
return_value=TokenData(sub=str(user.id), payload={"type": "access"})
47+
)
48+
get_user_by_id = AsyncMock(return_value=user)
49+
monkeypatch.setattr(auth_dep, "decode_access_token", cast(Any, decode_access_token))
50+
monkeypatch.setattr(auth_dep, "get_user_by_id", cast(Any, get_user_by_id))
51+
52+
result = await auth_dep.get_current_user(token="access-token", session=fake_session)
53+
54+
assert result is user
55+
get_user_by_id.assert_awaited_once_with(fake_session, user.id)
56+
57+
58+
@pytest.mark.asyncio
59+
async def test_get_current_user_raises_401_for_invalid_token(
60+
fake_session: AsyncSession,
61+
monkeypatch: pytest.MonkeyPatch,
62+
) -> None:
63+
"""Проверяет, что невалидный токен приводит к 401 и заголовку WWW-Authenticate."""
64+
decode_access_token = Mock(side_effect=TokenInvalid("bad token"))
65+
monkeypatch.setattr(auth_dep, "decode_access_token", cast(Any, decode_access_token))
66+
67+
with pytest.raises(HTTPException) as exc:
68+
await auth_dep.get_current_user(token="broken-token", session=fake_session)
69+
70+
assert exc.value.status_code == 401
71+
assert exc.value.headers == {"WWW-Authenticate": "Bearer"}
72+
73+
74+
@pytest.mark.asyncio
75+
async def test_get_current_user_raises_401_for_invalid_subject(
76+
fake_session: AsyncSession,
77+
monkeypatch: pytest.MonkeyPatch,
78+
) -> None:
79+
"""Проверяет ветку с некорректным sub в токене: должен возвращаться 401."""
80+
decode_access_token = Mock(
81+
return_value=TokenData(sub="not-a-uuid", payload={"type": "access"})
82+
)
83+
monkeypatch.setattr(auth_dep, "decode_access_token", cast(Any, decode_access_token))
84+
85+
with pytest.raises(HTTPException) as exc:
86+
await auth_dep.get_current_user(token="access-token", session=fake_session)
87+
88+
assert exc.value.status_code == 401
89+
assert exc.value.detail == "Некорректный идентификатор в токене"
90+
91+
92+
@pytest.mark.asyncio
93+
async def test_get_current_user_raises_401_when_user_not_found(
94+
fake_session: AsyncSession,
95+
monkeypatch: pytest.MonkeyPatch,
96+
) -> None:
97+
"""Проверяет, что при отсутствии пользователя в БД зависимость возвращает 401."""
98+
user_id = uuid4()
99+
decode_access_token = Mock(
100+
return_value=TokenData(sub=str(user_id), payload={"type": "access"})
101+
)
102+
get_user_by_id = AsyncMock(return_value=None)
103+
monkeypatch.setattr(auth_dep, "decode_access_token", cast(Any, decode_access_token))
104+
monkeypatch.setattr(auth_dep, "get_user_by_id", cast(Any, get_user_by_id))
105+
106+
with pytest.raises(HTTPException) as exc:
107+
await auth_dep.get_current_user(token="access-token", session=fake_session)
108+
109+
assert exc.value.status_code == 401
110+
assert exc.value.detail == "Сессия недействительна (пользователь не найден)"
111+
112+
113+
@pytest.mark.asyncio
114+
async def test_get_current_user_raises_403_for_inactive_user(
115+
fake_session: AsyncSession,
116+
monkeypatch: pytest.MonkeyPatch,
117+
) -> None:
118+
"""Проверяет, что неактивный пользователь получает 403 в зависимости get_current_user."""
119+
user = _make_user(is_active=False)
120+
decode_access_token = Mock(
121+
return_value=TokenData(sub=str(user.id), payload={"type": "access"})
122+
)
123+
get_user_by_id = AsyncMock(return_value=user)
124+
monkeypatch.setattr(auth_dep, "decode_access_token", cast(Any, decode_access_token))
125+
monkeypatch.setattr(auth_dep, "get_user_by_id", cast(Any, get_user_by_id))
126+
127+
with pytest.raises(HTTPException) as exc:
128+
await auth_dep.get_current_user(token="access-token", session=fake_session)
129+
130+
assert exc.value.status_code == 403
131+
assert exc.value.detail == "Аккаунт заблокирован. Обратитесь в поддержку."

tests/unit/security/test_jwt.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import pytest
77

88
from app.config import settings
9+
from app.security import jwt as jwt_service
910
from app.security.jwt import (
1011
TokenExpired,
1112
TokenInvalid,
@@ -21,6 +22,7 @@ def _unix_now() -> int:
2122

2223

2324
def test_create_and_decode_access_token_roundtrip() -> None:
25+
"""Проверяет, что access-токен корректно создаётся и декодируется обратно."""
2426
token = create_access_token(subject="user-123")
2527

2628
decoded = decode_access_token(token)
@@ -30,13 +32,15 @@ def test_create_and_decode_access_token_roundtrip() -> None:
3032

3133

3234
def test_decode_access_token_rejects_refresh_token() -> None:
35+
"""Проверяет, что refresh-токен нельзя декодировать как access-токен."""
3336
refresh_token, _ = create_refresh_token(subject="user-123")
3437

3538
with pytest.raises(TokenInvalid, match="Ожидался access токен"):
3639
decode_access_token(refresh_token)
3740

3841

3942
def test_decode_refresh_token_requires_jti() -> None:
43+
"""Проверяет, что refresh-токен без jti отвергается как невалидный."""
4044
payload = {
4145
"sub": "user-123",
4246
"iat": _unix_now(),
@@ -54,10 +58,68 @@ def test_decode_refresh_token_requires_jti() -> None:
5458

5559

5660
def test_decode_access_token_raises_for_expired_token() -> None:
61+
"""Проверяет, что истёкший access-токен приводит к TokenExpired."""
5762
expired_token = create_access_token(
5863
subject="user-123",
5964
expires_delta=timedelta(seconds=-1),
6065
)
6166

6267
with pytest.raises(TokenExpired, match="истёк"):
6368
decode_access_token(expired_token)
69+
70+
71+
def test_create_access_token_merges_extra_claims() -> None:
72+
"""Проверяет, что custom claims попадают в payload access-токена."""
73+
token = create_access_token(
74+
subject="user-123",
75+
extra_claims={"role": "admin"},
76+
)
77+
78+
decoded = decode_access_token(token)
79+
80+
assert decoded.payload["role"] == "admin"
81+
82+
83+
def test_decode_access_token_rejects_non_string_subject(
84+
monkeypatch: pytest.MonkeyPatch,
85+
) -> None:
86+
"""Проверяет, что _decode_token отклоняет payload с нестроковым sub."""
87+
monkeypatch.setattr(
88+
jwt_service.jwt,
89+
"decode",
90+
lambda **_: {
91+
"sub": 123,
92+
"iat": _unix_now(),
93+
"exp": _unix_now() + 60,
94+
"type": "access",
95+
},
96+
)
97+
98+
with pytest.raises(TokenInvalid, match="Ошибка субъекта"):
99+
decode_access_token("token")
100+
101+
102+
def test_decode_access_token_rejects_unknown_token_type() -> None:
103+
"""Проверяет, что токен с произвольным type отклоняется как невалидный."""
104+
payload = {
105+
"sub": "user-123",
106+
"iat": _unix_now(),
107+
"exp": _unix_now() + 60,
108+
"type": "unknown",
109+
}
110+
token = jwt.encode(
111+
payload=payload,
112+
key=settings.SECRET_KEY,
113+
algorithm=settings.ALGORITHM,
114+
)
115+
116+
with pytest.raises(TokenInvalid, match="Некорректный тип токена"):
117+
decode_access_token(token)
118+
119+
120+
def test_decode_refresh_token_rejects_access_token_type() -> None:
121+
"""Проверяет, что access-токен нельзя декодировать как refresh-токен."""
122+
access_token = create_access_token(subject="user-123")
123+
124+
with pytest.raises(TokenInvalid, match="Ожидался refresh токен"):
125+
decode_refresh_token(access_token)

0 commit comments

Comments
 (0)