Skip to content

Commit 5562505

Browse files
committed
feat: добавить dashboard и readiness
- добавить endpoint /api/v1/dashboard с параллельной агрегацией данных - добавить readiness-check для БД и Redis - покрыть новые ручки API- и unit-тестами
1 parent 0f2f5a4 commit 5562505

File tree

9 files changed

+278
-5
lines changed

9 files changed

+278
-5
lines changed

app/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from app.middleware import LoggingMiddleware, MetricsMiddleware, RequestIDMiddleware
88
from app.redis import redis_client
99
from app.routes.auth import router as auth_router
10+
from app.routes.dashboard import router as dashboard_router
1011
from app.routes.health import router as health_router
1112
from app.routes.metrics import router as metrics_router
1213
from app.routes.tags import router as tags_router
@@ -46,5 +47,6 @@ async def lifespan(app: FastAPI):
4647
app.include_router(metrics_router)
4748
app.include_router(auth_router, prefix=API_V1_PREFIX)
4849
app.include_router(health_router, prefix=API_V1_PREFIX)
50+
app.include_router(dashboard_router, prefix=API_V1_PREFIX)
4951
app.include_router(tasks_router, prefix=API_V1_PREFIX)
5052
app.include_router(tags_router, prefix=API_V1_PREFIX)

app/repositories/task_repo.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from collections.abc import Sequence
44
from uuid import UUID
55

6-
from sqlalchemy import asc, desc, or_, select
6+
from sqlalchemy import asc, desc, func, or_, select
77
from sqlalchemy.ext.asyncio import AsyncSession
88

99
from app.models.task import Task
@@ -67,6 +67,22 @@ async def get_tasks_by_user(
6767
return result.scalars().all()
6868

6969

70+
async def get_task_counts_by_status(
71+
session: AsyncSession, user: User
72+
) -> dict[str, int]:
73+
"""Возвращает количество задач пользователя по статусам (todo, in_progress, done)."""
74+
stmt = (
75+
select(Task.status, func.count(Task.id))
76+
.where(Task.user_id == user.id)
77+
.group_by(Task.status)
78+
)
79+
result = await session.execute(stmt)
80+
counts = {"todo": 0, "in_progress": 0, "done": 0}
81+
for status_val, count in result.all():
82+
counts[status_val] = count
83+
return counts
84+
85+
7086
async def create_task(session: AsyncSession, user: User, **data) -> Task:
7187
"""Создаёт задачу для пользователя."""
7288
task = Task(**data, user_id=user.id)

app/routes/dashboard.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
from typing import Annotated
5+
6+
from fastapi import APIRouter, Depends
7+
from sqlalchemy.ext.asyncio import AsyncSession
8+
9+
from app.database import get_db
10+
from app.get_or_404 import CurrentUser
11+
from app.repositories import task_repo
12+
from app.schemas.dashboard import DashboardRead, TaskCountsByStatus
13+
from app.schemas.tag import TagRead
14+
from app.schemas.task import TaskListFilters, TaskRead
15+
from app.services import tag as tag_service
16+
from app.services import task as task_service
17+
18+
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
19+
DbSession = Annotated[AsyncSession, Depends(get_db)]
20+
21+
# Ограниченный набор задач для дашборда (последние по дате создания).
22+
_DASHBOARD_TASK_LIMIT = 10
23+
24+
25+
@router.get("", summary="Дашборд: задачи, теги и счётчики по статусам")
26+
async def get_dashboard(
27+
current_user: CurrentUser,
28+
session: DbSession,
29+
) -> DashboardRead:
30+
"""Возвращает задачи (с лимитом), теги и счётчики задач по статусам.
31+
Три независимых чтения выполняются параллельно (asyncio.gather).
32+
"""
33+
filters = TaskListFilters(limit=_DASHBOARD_TASK_LIMIT, offset=0)
34+
tasks_result, tags_result, counts_result = await asyncio.gather(
35+
task_service.get_user_tasks(session, current_user, filters),
36+
tag_service.list_tags(session),
37+
task_repo.get_task_counts_by_status(session, current_user),
38+
)
39+
return DashboardRead(
40+
tasks=[TaskRead.model_validate(t) for t in tasks_result],
41+
tags=[TagRead.model_validate(t) for t in tags_result],
42+
counts_by_status=TaskCountsByStatus(**counts_result),
43+
)

app/routes/health.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import asyncio
12
from typing import Annotated, Awaitable, cast
23

3-
from fastapi import APIRouter, Depends
4+
from fastapi import APIRouter, Depends, Response
45
from redis.asyncio import Redis
56
from sqlalchemy import text
67
from sqlalchemy.ext.asyncio import AsyncSession
@@ -23,3 +24,36 @@ async def redis_health(redis: RedisClient) -> dict[str, str]:
2324
async def db_health(session: DbSession) -> dict[str, str]:
2425
await session.execute(text("SELECT 1"))
2526
return {"db": "ok"}
27+
28+
29+
@router.get("/ready")
30+
async def readiness(
31+
redis: RedisClient, session: DbSession, response: Response
32+
) -> dict[str, str]:
33+
"""Проверка готовности: БД и Redis параллельно. Время ответа ≈ max(db, redis)."""
34+
db_result, redis_result = await asyncio.gather(
35+
_check_db(session),
36+
_check_redis(redis),
37+
)
38+
status_db, status_redis = db_result["db"], redis_result["redis"]
39+
if status_db == "ok" and status_redis == "ok":
40+
response.status_code = 200
41+
else:
42+
response.status_code = 503
43+
return {"db": status_db, "redis": status_redis}
44+
45+
46+
async def _check_db(session: DbSession) -> dict[str, str]:
47+
try:
48+
await session.execute(text("SELECT 1"))
49+
return {"db": "ok"}
50+
except Exception:
51+
return {"db": "down"}
52+
53+
54+
async def _check_redis(redis: RedisClient) -> dict[str, str]:
55+
try:
56+
ok = await cast(Awaitable[bool], redis.ping())
57+
return {"redis": "ok" if ok else "down"}
58+
except Exception:
59+
return {"redis": "down"}

app/schemas/dashboard.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from __future__ import annotations
2+
3+
from pydantic import BaseModel
4+
5+
from app.schemas.tag import TagRead
6+
from app.schemas.task import TaskRead
7+
8+
9+
class TaskCountsByStatus(BaseModel):
10+
"""Количество задач пользователя по статусам."""
11+
12+
todo: int = 0
13+
in_progress: int = 0
14+
done: int = 0
15+
16+
17+
class DashboardRead(BaseModel):
18+
"""Данные дашборда: последние задачи, все теги, счётчики по статусам."""
19+
20+
tasks: list[TaskRead]
21+
tags: list[TagRead]
22+
counts_by_status: TaskCountsByStatus

tests/api/conftest.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from datetime import UTC, datetime
55
from types import SimpleNamespace
66
from typing import cast
7+
from unittest.mock import AsyncMock
78
from uuid import uuid4
89

910
import pytest
@@ -16,6 +17,8 @@
1617
from app.models.user import User
1718
from app.redis import get_redis
1819
from app.routes.auth import router as auth_router
20+
from app.routes.dashboard import router as dashboard_router
21+
from app.routes.health import router as health_router
1922
from app.routes.tags import router as tags_router
2023
from app.routes.tasks import router as tasks_router
2124
from app.security.dependences import get_current_user
@@ -34,6 +37,13 @@ def __init__(self) -> None:
3437
self.raise_on_get = False
3538
self.raise_on_set = False
3639
self.raise_on_delete = False
40+
self.raise_on_ping = False
41+
42+
async def ping(self) -> bool:
43+
"""Эмуляция Redis PING для health-check."""
44+
if self.raise_on_ping:
45+
raise RedisConnectionError("Ошибка соединения с Redis")
46+
return True
3747

3848
async def eval(
3949
self, script: str, number_of_keys: int, key: str, window_seconds: int
@@ -93,8 +103,10 @@ async def delete(self, *keys: str) -> int:
93103

94104
@pytest.fixture
95105
def fake_session() -> object:
96-
"""Возвращает объект-заглушку сессии для API-тестов."""
97-
return object()
106+
"""Возвращает объект-заглушку сессии для API-тестов (с execute для health)."""
107+
session = AsyncMock()
108+
session.execute = AsyncMock()
109+
return session
98110

99111

100112
@pytest.fixture
@@ -123,6 +135,8 @@ def api_app(fake_session: object, fake_redis: FakeRedis, current_user: User) ->
123135
"""Собирает тестовое FastAPI-приложение с override зависимостей."""
124136
app = FastAPI()
125137
app.include_router(auth_router, prefix="/api/v1")
138+
app.include_router(health_router, prefix="/api/v1")
139+
app.include_router(dashboard_router, prefix="/api/v1")
126140
app.include_router(tasks_router, prefix="/api/v1")
127141
app.include_router(tags_router, prefix="/api/v1")
128142

tests/api/test_dashboard_routes.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""API-тесты для dashboard endpoint."""
2+
3+
from __future__ import annotations
4+
5+
from unittest.mock import AsyncMock
6+
7+
import pytest
8+
from httpx import AsyncClient
9+
10+
from app.routes import dashboard as dashboard_routes
11+
12+
pytestmark = pytest.mark.asyncio
13+
14+
15+
async def test_dashboard_200_returns_tasks_tags_and_counts(
16+
client: AsyncClient,
17+
current_user,
18+
monkeypatch: pytest.MonkeyPatch,
19+
) -> None:
20+
"""GET /dashboard возвращает 200 и тело с tasks, tags, counts_by_status."""
21+
from app.services import tag as tag_service
22+
from app.services import task as task_service
23+
24+
from tests.api.test_tasks_routes import _make_task, _make_tag
25+
26+
task = _make_task(current_user, title="Dashboard task")
27+
tag = _make_tag(name="dashboard-tag")
28+
monkeypatch.setattr(
29+
task_service,
30+
"get_user_tasks",
31+
AsyncMock(return_value=[task]),
32+
)
33+
monkeypatch.setattr(
34+
tag_service,
35+
"list_tags",
36+
AsyncMock(return_value=[tag]),
37+
)
38+
monkeypatch.setattr(
39+
dashboard_routes.task_repo,
40+
"get_task_counts_by_status",
41+
AsyncMock(return_value={"todo": 1, "in_progress": 0, "done": 0}),
42+
)
43+
44+
response = await client.get("/api/v1/dashboard")
45+
46+
assert response.status_code == 200
47+
body = response.json()
48+
assert "tasks" in body
49+
assert "tags" in body
50+
assert "counts_by_status" in body
51+
assert len(body["tasks"]) == 1
52+
assert body["tasks"][0]["title"] == "Dashboard task"
53+
assert len(body["tags"]) == 1
54+
assert body["tags"][0]["name"] == "dashboard-tag"
55+
assert body["counts_by_status"] == {"todo": 1, "in_progress": 0, "done": 0}

tests/api/test_readiness.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""API-тесты для readiness endpoint."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
from httpx import AsyncClient
7+
8+
pytestmark = pytest.mark.asyncio
9+
10+
11+
async def test_readiness_200_when_both_ok(client: AsyncClient) -> None:
12+
"""GET /health/ready возвращает 200 и ok для db и redis."""
13+
response = await client.get("/api/v1/health/ready")
14+
assert response.status_code == 200
15+
body = response.json()
16+
assert body["db"] == "ok"
17+
assert body["redis"] == "ok"
18+
19+
20+
async def test_readiness_503_when_redis_down(client: AsyncClient, fake_redis) -> None:
21+
"""GET /health/ready возвращает 503, когда Redis недоступен."""
22+
fake_redis.raise_on_ping = True
23+
response = await client.get("/api/v1/health/ready")
24+
assert response.status_code == 503
25+
body = response.json()
26+
assert body["db"] == "ok"
27+
assert body["redis"] == "down"

tests/unit/routes/test_health_routes.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,17 @@
44
from unittest.mock import AsyncMock
55

66
import pytest
7+
from fastapi import Response
78
from redis.asyncio import Redis
89
from sqlalchemy.ext.asyncio import AsyncSession
910

10-
from app.routes.health import db_health, redis_health
11+
from app.routes.health import (
12+
_check_db,
13+
_check_redis,
14+
db_health,
15+
readiness,
16+
redis_health,
17+
)
1118

1219

1320
@pytest.fixture
@@ -53,3 +60,56 @@ async def test_db_health_executes_ping_query(fake_session: AsyncSession) -> None
5360

5461
assert result == {"db": "ok"}
5562
cast(AsyncMock, fake_session.execute).assert_awaited_once()
63+
64+
65+
@pytest.mark.asyncio
66+
async def test_check_db_returns_down_on_exception(fake_session: AsyncSession) -> None:
67+
"""_check_db возвращает db=down при исключении."""
68+
cast(AsyncMock, fake_session.execute).side_effect = Exception("connection lost")
69+
result = await _check_db(fake_session)
70+
assert result == {"db": "down"}
71+
72+
73+
@pytest.mark.asyncio
74+
async def test_check_redis_returns_down_on_exception(fake_redis: Redis) -> None:
75+
"""_check_redis возвращает redis=down при исключении."""
76+
cast(AsyncMock, fake_redis.ping).side_effect = Exception("connection refused")
77+
result = await _check_redis(fake_redis)
78+
assert result == {"redis": "down"}
79+
80+
81+
@pytest.mark.asyncio
82+
async def test_readiness_200_when_both_ok(
83+
fake_redis: Redis, fake_session: AsyncSession
84+
) -> None:
85+
"""Readiness возвращает 200 и ok для db и redis, когда оба источника доступны."""
86+
cast(AsyncMock, fake_redis.ping).return_value = True
87+
response = Response()
88+
result = await readiness(redis=fake_redis, session=fake_session, response=response)
89+
assert result == {"db": "ok", "redis": "ok"}
90+
assert response.status_code == 200
91+
92+
93+
@pytest.mark.asyncio
94+
async def test_readiness_503_when_db_down(
95+
fake_redis: Redis, fake_session: AsyncSession
96+
) -> None:
97+
"""Readiness возвращает 503, когда БД недоступна."""
98+
cast(AsyncMock, fake_redis.ping).return_value = True
99+
cast(AsyncMock, fake_session.execute).side_effect = Exception("db down")
100+
response = Response()
101+
result = await readiness(redis=fake_redis, session=fake_session, response=response)
102+
assert result == {"db": "down", "redis": "ok"}
103+
assert response.status_code == 503
104+
105+
106+
@pytest.mark.asyncio
107+
async def test_readiness_503_when_redis_down(
108+
fake_redis: Redis, fake_session: AsyncSession
109+
) -> None:
110+
"""Readiness возвращает 503, когда Redis недоступен."""
111+
cast(AsyncMock, fake_redis.ping).side_effect = Exception("redis down")
112+
response = Response()
113+
result = await readiness(redis=fake_redis, session=fake_session, response=response)
114+
assert result == {"db": "ok", "redis": "down"}
115+
assert response.status_code == 503

0 commit comments

Comments
 (0)