Skip to content

Commit cf2eff5

Browse files
committed
test: добавить api-тесты для auth, tasks и tags
- Добавлены фикстуры клиента и авторизации для API тестов - Проверены позитивные и негативные сценарии auth - Проверены CRUD и проверки доступа для tasks и tags
1 parent b4c91ae commit cf2eff5

4 files changed

Lines changed: 847 additions & 0 deletions

File tree

tests/api/conftest.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import AsyncIterator
4+
from datetime import UTC, datetime
5+
from types import SimpleNamespace
6+
from typing import cast
7+
from uuid import uuid4
8+
9+
import pytest
10+
import pytest_asyncio
11+
from fastapi import FastAPI
12+
from httpx import ASGITransport, AsyncClient
13+
14+
from app.database import get_db
15+
from app.models.user import User
16+
from app.redis import get_redis
17+
from app.routes.auth import router as auth_router
18+
from app.routes.tags import router as tags_router
19+
from app.routes.tasks import router as tasks_router
20+
from app.security.dependences import get_current_user
21+
22+
23+
class FakeRedis:
24+
def __init__(self) -> None:
25+
self.store: dict[str, str] = {}
26+
self.eval_result = 1
27+
self.ttl_result = 60
28+
self.forced_delete_result: int | None = None
29+
self.set_calls: list[tuple[str, str, int]] = []
30+
self.delete_calls: list[str] = []
31+
32+
async def eval(
33+
self, script: str, number_of_keys: int, key: str, window_seconds: int
34+
) -> int:
35+
_ = (script, number_of_keys, key, window_seconds)
36+
return self.eval_result
37+
38+
async def ttl(self, key: str) -> int:
39+
_ = key
40+
return self.ttl_result
41+
42+
async def set(self, key: str, value: str, ex: int) -> bool:
43+
self.store[key] = value
44+
self.set_calls.append((key, value, ex))
45+
return True
46+
47+
async def delete(self, key: str) -> int:
48+
self.delete_calls.append(key)
49+
if self.forced_delete_result is not None:
50+
if self.forced_delete_result == 1:
51+
self.store.pop(key, None)
52+
return self.forced_delete_result
53+
54+
existed = key in self.store
55+
self.store.pop(key, None)
56+
return 1 if existed else 0
57+
58+
59+
@pytest.fixture
60+
def fake_session() -> object:
61+
return object()
62+
63+
64+
@pytest.fixture
65+
def fake_redis() -> FakeRedis:
66+
return FakeRedis()
67+
68+
69+
@pytest.fixture
70+
def current_user() -> User:
71+
return cast(
72+
User,
73+
SimpleNamespace(
74+
id=uuid4(),
75+
email="current@example.com",
76+
hashed_password="hashed",
77+
is_active=True,
78+
created_at=datetime.now(UTC),
79+
),
80+
)
81+
82+
83+
@pytest.fixture
84+
def api_app(fake_session: object, fake_redis: FakeRedis, current_user: User) -> FastAPI:
85+
app = FastAPI()
86+
app.include_router(auth_router, prefix="/api/v1")
87+
app.include_router(tasks_router, prefix="/api/v1")
88+
app.include_router(tags_router, prefix="/api/v1")
89+
90+
async def override_db() -> AsyncIterator[object]:
91+
yield fake_session
92+
93+
async def override_redis() -> FakeRedis:
94+
return fake_redis
95+
96+
async def override_current_user() -> User:
97+
return current_user
98+
99+
app.dependency_overrides[get_db] = override_db
100+
app.dependency_overrides[get_redis] = override_redis
101+
app.dependency_overrides[get_current_user] = override_current_user
102+
return app
103+
104+
105+
@pytest_asyncio.fixture
106+
async def client(api_app: FastAPI) -> AsyncIterator[AsyncClient]:
107+
transport = ASGITransport(app=api_app)
108+
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
109+
yield ac

tests/api/test_auth_routes.py

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
from __future__ import annotations
2+
3+
from datetime import UTC, datetime
4+
from types import SimpleNamespace
5+
from typing import cast
6+
from unittest.mock import AsyncMock, Mock
7+
from uuid import uuid4
8+
9+
import pytest
10+
from httpx import AsyncClient
11+
12+
from app.models.user import User
13+
from app.routes import auth as auth_routes
14+
from tests.api.conftest import FakeRedis
15+
16+
pytestmark = pytest.mark.asyncio
17+
18+
19+
def _make_user(email: str, *, is_active: bool = True) -> User:
20+
return cast(
21+
User,
22+
SimpleNamespace(
23+
id=uuid4(),
24+
email=email,
25+
hashed_password="hashed-password",
26+
is_active=is_active,
27+
created_at=datetime.now(UTC),
28+
),
29+
)
30+
31+
32+
async def test_register_returns_201_and_user_payload(
33+
client: AsyncClient,
34+
fake_session: object,
35+
monkeypatch: pytest.MonkeyPatch,
36+
) -> None:
37+
"""Проверяет успешную регистрацию: код 201 и корректный payload пользователя."""
38+
new_user = _make_user("new@example.com")
39+
register_user = AsyncMock(return_value=new_user)
40+
monkeypatch.setattr(auth_routes, "register_user", register_user)
41+
42+
response = await client.post(
43+
"/api/v1/auth/register",
44+
json={"email": "new@example.com", "password": "strong-pass-123"},
45+
)
46+
47+
assert response.status_code == 201
48+
body = response.json()
49+
assert body["email"] == "new@example.com"
50+
assert body["is_active"] is True
51+
register_user.assert_awaited_once_with(
52+
fake_session,
53+
"new@example.com",
54+
"strong-pass-123",
55+
)
56+
57+
58+
async def test_register_returns_409_when_email_already_exists(
59+
client: AsyncClient,
60+
monkeypatch: pytest.MonkeyPatch,
61+
) -> None:
62+
"""Проверяет, что при конфликте email роут регистрации возвращает 409."""
63+
register_user = AsyncMock(side_effect=auth_routes.UserAlreadyExists)
64+
monkeypatch.setattr(auth_routes, "register_user", register_user)
65+
66+
response = await client.post(
67+
"/api/v1/auth/register",
68+
json={"email": "existing@example.com", "password": "strong-pass-123"},
69+
)
70+
71+
assert response.status_code == 409
72+
assert response.json()["detail"] == "Пользователь с таким Email уже зарегистрирован"
73+
74+
75+
async def test_login_returns_200_and_token_pair(
76+
client: AsyncClient,
77+
fake_redis: FakeRedis,
78+
monkeypatch: pytest.MonkeyPatch,
79+
) -> None:
80+
"""Проверяет успешный логин: выдачу пары токенов и запись refresh в Redis."""
81+
user = _make_user("user@example.com")
82+
authenticate_active_user = AsyncMock(return_value=user)
83+
monkeypatch.setattr(
84+
auth_routes,
85+
"authenticate_active_user",
86+
authenticate_active_user,
87+
)
88+
89+
response = await client.post(
90+
"/api/v1/auth/login",
91+
data={"username": "user@example.com", "password": "password-123"},
92+
)
93+
94+
assert response.status_code == 200
95+
body = response.json()
96+
assert body["token_type"] == "bearer"
97+
assert body["access_token"]
98+
assert body["refresh_token"]
99+
assert len(fake_redis.set_calls) == 1
100+
assert fake_redis.set_calls[0][0].startswith(f"rt:{user.id}:")
101+
102+
103+
async def test_login_returns_401_when_credentials_invalid(
104+
client: AsyncClient,
105+
monkeypatch: pytest.MonkeyPatch,
106+
) -> None:
107+
"""Проверяет, что неверные credentials на логине дают 401 и WWW-Authenticate."""
108+
authenticate_active_user = AsyncMock(return_value=None)
109+
monkeypatch.setattr(
110+
auth_routes,
111+
"authenticate_active_user",
112+
authenticate_active_user,
113+
)
114+
115+
response = await client.post(
116+
"/api/v1/auth/login",
117+
data={"username": "user@example.com", "password": "wrong-password"},
118+
)
119+
120+
assert response.status_code == 401
121+
assert response.json()["detail"] == "Неверный логин или пароль"
122+
assert response.headers.get("www-authenticate") == "bearer"
123+
124+
125+
async def test_login_returns_403_when_user_inactive(
126+
client: AsyncClient,
127+
monkeypatch: pytest.MonkeyPatch,
128+
) -> None:
129+
"""Проверяет, что заблокированный пользователь получает 403 на логине."""
130+
authenticate_active_user = AsyncMock(side_effect=auth_routes.UserInactive)
131+
monkeypatch.setattr(
132+
auth_routes,
133+
"authenticate_active_user",
134+
authenticate_active_user,
135+
)
136+
137+
response = await client.post(
138+
"/api/v1/auth/login",
139+
data={"username": "inactive@example.com", "password": "password-123"},
140+
)
141+
142+
assert response.status_code == 403
143+
assert response.json()["detail"] == "Аккаунт заблокирован"
144+
145+
146+
async def test_refresh_returns_401_when_refresh_token_invalid(
147+
client: AsyncClient,
148+
) -> None:
149+
"""Проверяет, что невалидный refresh-токен отклоняется с 401."""
150+
response = await client.post(
151+
"/api/v1/auth/refresh",
152+
json={"refresh_token": "not-a-jwt"},
153+
)
154+
155+
assert response.status_code == 401
156+
assert "Недействительный" in response.json()["detail"]
157+
158+
159+
async def test_refresh_returns_401_when_subject_is_not_uuid(
160+
client: AsyncClient,
161+
monkeypatch: pytest.MonkeyPatch,
162+
) -> None:
163+
"""Проверяет, что refresh отклоняется с 401 при невалидном user_id в sub."""
164+
token_data = SimpleNamespace(sub="not-a-uuid", payload={"jti": "jti-1"})
165+
decode_refresh_token = Mock(return_value=token_data)
166+
monkeypatch.setattr(auth_routes, "decode_refresh_token", decode_refresh_token)
167+
168+
response = await client.post(
169+
"/api/v1/auth/refresh",
170+
json={"refresh_token": "any-token"},
171+
)
172+
173+
assert response.status_code == 401
174+
assert (
175+
response.json()["detail"]
176+
== "Некорректный идентификатор пользователя в refresh токене"
177+
)
178+
179+
180+
async def test_refresh_returns_401_when_token_revoked_or_reused(
181+
client: AsyncClient,
182+
fake_redis: FakeRedis,
183+
monkeypatch: pytest.MonkeyPatch,
184+
) -> None:
185+
"""Проверяет, что отозванный/повторно использованный refresh-токен возвращает 401."""
186+
user_id = uuid4()
187+
fake_redis.forced_delete_result = 0
188+
parse_refresh = Mock(return_value=(user_id, "jti-1"))
189+
require_active = AsyncMock()
190+
monkeypatch.setattr(auth_routes, "_parse_refresh_token_or_401", parse_refresh)
191+
monkeypatch.setattr(auth_routes, "require_active_refresh_user", require_active)
192+
193+
response = await client.post(
194+
"/api/v1/auth/refresh",
195+
json={"refresh_token": "any-token"},
196+
)
197+
198+
assert response.status_code == 401
199+
assert response.json()["detail"] == "Refresh токен отозван или уже использован"
200+
201+
202+
async def test_refresh_returns_200_and_rotates_tokens(
203+
client: AsyncClient,
204+
fake_redis: FakeRedis,
205+
monkeypatch: pytest.MonkeyPatch,
206+
) -> None:
207+
"""Проверяет ротацию токенов: старый refresh инвалидируется, новая пара выдается."""
208+
user = _make_user("user@example.com")
209+
authenticate_active_user = AsyncMock(return_value=user)
210+
require_active = AsyncMock()
211+
monkeypatch.setattr(
212+
auth_routes,
213+
"authenticate_active_user",
214+
authenticate_active_user,
215+
)
216+
monkeypatch.setattr(auth_routes, "require_active_refresh_user", require_active)
217+
218+
login_response = await client.post(
219+
"/api/v1/auth/login",
220+
data={"username": "user@example.com", "password": "password-123"},
221+
)
222+
old_refresh = login_response.json()["refresh_token"]
223+
224+
response = await client.post(
225+
"/api/v1/auth/refresh",
226+
json={"refresh_token": old_refresh},
227+
)
228+
229+
assert response.status_code == 200
230+
body = response.json()
231+
assert body["token_type"] == "bearer"
232+
assert body["refresh_token"] != old_refresh
233+
assert len(fake_redis.delete_calls) >= 1
234+
assert len(fake_redis.set_calls) == 2
235+
236+
237+
async def test_logout_returns_204(
238+
client: AsyncClient,
239+
monkeypatch: pytest.MonkeyPatch,
240+
) -> None:
241+
"""Проверяет, что logout по валидному refresh завершает с 204."""
242+
user = _make_user("user@example.com")
243+
authenticate_active_user = AsyncMock(return_value=user)
244+
require_active = AsyncMock()
245+
monkeypatch.setattr(
246+
auth_routes,
247+
"authenticate_active_user",
248+
authenticate_active_user,
249+
)
250+
monkeypatch.setattr(auth_routes, "require_active_refresh_user", require_active)
251+
252+
login_response = await client.post(
253+
"/api/v1/auth/login",
254+
data={"username": "user@example.com", "password": "password-123"},
255+
)
256+
refresh_token = login_response.json()["refresh_token"]
257+
258+
response = await client.post(
259+
"/api/v1/auth/logout",
260+
json={"refresh_token": refresh_token},
261+
)
262+
263+
assert response.status_code == 204
264+
265+
266+
async def test_me_returns_current_user_payload(client: AsyncClient) -> None:
267+
"""Проверяет, что /me возвращает данные текущего аутентифицированного пользователя."""
268+
response = await client.get("/api/v1/auth/me")
269+
270+
assert response.status_code == 200
271+
body = response.json()
272+
assert body["email"] == "current@example.com"
273+
assert body["is_active"] is True

0 commit comments

Comments
 (0)