Skip to content

Commit a32fc24

Browse files
committed
test: добавить интеграционные тесты для репозиториев и сервисов
- Добавлены фикстуры интеграционного слоя для БД - Проверены user, task и tag репозитории - Проверены auth и связка task-tag на уровне сервисов
1 parent cf2eff5 commit a32fc24

6 files changed

Lines changed: 532 additions & 0 deletions

File tree

tests/integration/conftest.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from collections.abc import AsyncIterator
5+
from pathlib import Path
6+
from uuid import uuid4
7+
8+
import pytest
9+
import pytest_asyncio
10+
from sqlalchemy import text
11+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
12+
13+
from app.config import settings
14+
from app.database import Base
15+
16+
_DUMMY_DB_URL = "postgresql+asyncpg://test_user:test_password@localhost:5432/test_db"
17+
18+
19+
def _integration_db_url() -> str:
20+
explicit = os.getenv("INTEGRATION_DATABASE_URL")
21+
if explicit:
22+
return explicit
23+
24+
env_db_url = os.getenv("DATABASE_URL")
25+
if env_db_url and env_db_url != _DUMMY_DB_URL:
26+
return env_db_url
27+
28+
env_file = Path(__file__).resolve().parents[2] / ".env"
29+
if env_file.exists():
30+
for line in env_file.read_text(encoding="utf-8").splitlines():
31+
if line.startswith("DATABASE_URL="):
32+
return line.split("=", 1)[1].strip()
33+
34+
return settings.DATABASE_URL
35+
36+
37+
@pytest_asyncio.fixture
38+
async def db_session() -> AsyncIterator[AsyncSession]:
39+
# Важно: импорт моделей регистрирует таблицы в Base.metadata.
40+
import app.models.tag # noqa: F401
41+
import app.models.task # noqa: F401
42+
import app.models.user # noqa: F401
43+
44+
db_url = _integration_db_url()
45+
schema = f"test_{uuid4().hex}"
46+
admin_engine = create_async_engine(db_url, echo=False)
47+
test_engine = None
48+
schema_created = False
49+
50+
try:
51+
async with admin_engine.begin() as conn:
52+
await conn.execute(text(f'CREATE SCHEMA "{schema}"'))
53+
schema_created = True
54+
55+
test_engine = create_async_engine(
56+
db_url,
57+
echo=False,
58+
connect_args={"server_settings": {"search_path": schema}},
59+
)
60+
async with test_engine.begin() as conn:
61+
await conn.run_sync(Base.metadata.create_all)
62+
session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
63+
64+
async with session_factory() as session:
65+
yield session
66+
except Exception as exc:
67+
pytest.skip(f"Интеграционная БД недоступна: {exc}")
68+
finally:
69+
if test_engine is not None:
70+
await test_engine.dispose()
71+
if schema_created:
72+
try:
73+
async with admin_engine.begin() as conn:
74+
await conn.execute(
75+
text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE')
76+
)
77+
except Exception:
78+
pass
79+
await admin_engine.dispose()
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
from __future__ import annotations
2+
3+
from uuid import uuid4
4+
5+
import pytest
6+
from sqlalchemy import select
7+
from sqlalchemy.ext.asyncio import AsyncSession
8+
9+
from app.models.tag import task_tags
10+
from app.repositories import tag_repo, task_repo, user_repo
11+
12+
pytestmark = pytest.mark.integration
13+
14+
15+
@pytest.mark.asyncio
16+
async def test_tag_repo_create_update_delete_cycle(db_session: AsyncSession) -> None:
17+
"""Проверяет CRUD-цикл тега через репозиторий."""
18+
tag = await tag_repo.create_tag(
19+
db_session,
20+
name=f"tag-{uuid4().hex[:6]}",
21+
color="#22AAFF",
22+
)
23+
assert tag.id is not None
24+
25+
loaded = await tag_repo.get_tag_by_id(db_session, tag.id)
26+
assert loaded is not None
27+
assert loaded.color == "#22AAFF"
28+
29+
updated = await tag_repo.update_tag(db_session, loaded, name="renamed-tag")
30+
assert updated.name == "renamed-tag"
31+
32+
deleted_id = await tag_repo.delete_tag(db_session, updated)
33+
assert deleted_id == tag.id
34+
assert await tag_repo.get_tag_by_id(db_session, tag.id) is None
35+
36+
37+
@pytest.mark.asyncio
38+
async def test_list_tags_returns_sorted_by_name(db_session: AsyncSession) -> None:
39+
"""Проверяет сортировку list_tags по имени по возрастанию."""
40+
await tag_repo.create_tag(db_session, name="zeta", color="#000001")
41+
await tag_repo.create_tag(db_session, name="alpha", color="#000002")
42+
await tag_repo.create_tag(db_session, name="beta", color="#000003")
43+
44+
tags = await tag_repo.list_tags(db_session)
45+
names = [tag.name for tag in tags]
46+
47+
assert names == ["alpha", "beta", "zeta"]
48+
49+
50+
@pytest.mark.asyncio
51+
async def test_attach_detach_tag_idempotence(db_session: AsyncSession) -> None:
52+
"""Проверяет идемпотентность attach/detach связи task-tag на уровне репозитория."""
53+
user = await user_repo.create_user(
54+
db_session,
55+
email=f"tag-owner-{uuid4().hex[:8]}@example.com",
56+
hashed_password="hashed",
57+
)
58+
task = await task_repo.create_task(db_session, user, title="Task for tag")
59+
tag = await tag_repo.create_tag(db_session, name="idempotent", color="#FF0000")
60+
61+
await tag_repo.attach_tag_to_task(db_session, task, tag)
62+
await tag_repo.attach_tag_to_task(db_session, task, tag)
63+
64+
rows = await db_session.execute(
65+
select(task_tags.c.task_id, task_tags.c.tag_id).where(
66+
task_tags.c.task_id == task.id,
67+
task_tags.c.tag_id == tag.id,
68+
)
69+
)
70+
assert len(rows.all()) == 1
71+
72+
await tag_repo.detach_tag_from_task(db_session, task, tag)
73+
await tag_repo.detach_tag_from_task(db_session, task, tag)
74+
75+
rows_after = await db_session.execute(
76+
select(task_tags.c.task_id, task_tags.c.tag_id).where(
77+
task_tags.c.task_id == task.id,
78+
task_tags.c.tag_id == tag.id,
79+
)
80+
)
81+
assert rows_after.first() is None
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from __future__ import annotations
2+
3+
from uuid import uuid4
4+
5+
import pytest
6+
from sqlalchemy.ext.asyncio import AsyncSession
7+
8+
from app.models.task import StatusEnum
9+
from app.repositories import task_repo, user_repo
10+
11+
pytestmark = pytest.mark.integration
12+
13+
14+
@pytest.mark.asyncio
15+
async def test_task_repo_crud_cycle(db_session: AsyncSession) -> None:
16+
"""Проверяет полный CRUD-цикл задачи через репозиторий."""
17+
user = await user_repo.create_user(
18+
db_session,
19+
email=f"task-owner-{uuid4().hex[:8]}@example.com",
20+
hashed_password="hashed",
21+
)
22+
23+
created = await task_repo.create_task(
24+
db_session,
25+
user,
26+
title="Repo task",
27+
description="created by repo test",
28+
)
29+
assert created.user_id == user.id
30+
31+
loaded = await task_repo.get_task_by_id(db_session, created.id)
32+
assert loaded is not None
33+
assert loaded.title == "Repo task"
34+
35+
updated = await task_repo.update_task(
36+
db_session,
37+
loaded,
38+
title="Updated by repo",
39+
status=StatusEnum.done,
40+
)
41+
assert updated.title == "Updated by repo"
42+
assert updated.status == StatusEnum.done
43+
44+
deleted_id = await task_repo.delete_task(db_session, updated)
45+
assert deleted_id == created.id
46+
assert await task_repo.get_task_by_id(db_session, created.id) is None
47+
48+
49+
@pytest.mark.asyncio
50+
async def test_get_tasks_by_user_filters_foreign_tasks(
51+
db_session: AsyncSession,
52+
) -> None:
53+
"""Проверяет, что get_tasks_by_user возвращает только задачи указанного пользователя."""
54+
user_a = await user_repo.create_user(
55+
db_session,
56+
email=f"user-a-{uuid4().hex[:8]}@example.com",
57+
hashed_password="hash-a",
58+
)
59+
user_b = await user_repo.create_user(
60+
db_session,
61+
email=f"user-b-{uuid4().hex[:8]}@example.com",
62+
hashed_password="hash-b",
63+
)
64+
await task_repo.create_task(db_session, user_a, title="A1")
65+
await task_repo.create_task(db_session, user_a, title="A2")
66+
await task_repo.create_task(db_session, user_b, title="B1")
67+
68+
tasks_a = await task_repo.get_tasks_by_user(db_session, user_a)
69+
titles = sorted(task.title for task in tasks_a)
70+
71+
assert titles == ["A1", "A2"]
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from __future__ import annotations
2+
3+
from uuid import uuid4
4+
5+
import pytest
6+
from sqlalchemy.exc import IntegrityError
7+
from sqlalchemy.ext.asyncio import AsyncSession
8+
9+
from app.repositories import user_repo
10+
11+
pytestmark = pytest.mark.integration
12+
13+
14+
@pytest.mark.asyncio
15+
async def test_create_and_get_user_by_email(db_session: AsyncSession) -> None:
16+
"""Проверяет создание пользователя в repo и последующую загрузку по email."""
17+
email = f"user-{uuid4().hex[:8]}@example.com"
18+
created = await user_repo.create_user(
19+
db_session,
20+
email=email,
21+
hashed_password="hashed-password",
22+
)
23+
24+
loaded = await user_repo.get_user_by_email(db_session, email)
25+
26+
assert loaded is not None
27+
assert loaded.id == created.id
28+
assert loaded.email == email
29+
30+
31+
@pytest.mark.asyncio
32+
async def test_get_user_by_id_returns_none_for_unknown_id(
33+
db_session: AsyncSession,
34+
) -> None:
35+
"""Проверяет, что repo возвращает None для несуществующего user_id."""
36+
loaded = await user_repo.get_user_by_id(db_session, uuid4())
37+
38+
assert loaded is None
39+
40+
41+
@pytest.mark.asyncio
42+
async def test_create_user_enforces_unique_email(db_session: AsyncSession) -> None:
43+
"""Проверяет уникальность email на уровне БД при повторном create_user."""
44+
email = f"user-{uuid4().hex[:8]}@example.com"
45+
await user_repo.create_user(
46+
db_session,
47+
email=email,
48+
hashed_password="hash-1",
49+
)
50+
51+
with pytest.raises(IntegrityError):
52+
await user_repo.create_user(
53+
db_session,
54+
email=email,
55+
hashed_password="hash-2",
56+
)
57+
58+
await db_session.rollback()

0 commit comments

Comments
 (0)