From 74453980dcb386354fc58fc671fbfdeb942af265 Mon Sep 17 00:00:00 2001 From: sardor014 Date: Sun, 21 Dec 2025 18:32:34 +0300 Subject: [PATCH 01/13] feat(backend): add tests for admin router and admin service --- docker-compose.yaml | 19 ++ pyproject.toml | 12 + src/application/services/admin_service.py | 6 +- src/core/settings.py | 1 + .../db/repositories/sqlalchemy_user_repo.py | 8 +- tests/api/test_admin_router.py | 237 ++++++++++++++++ tests/conftest.py | 32 +++ tests/integration/test_user_repo.py | 248 ++++++++++++++++ tests/unit/test_admin_service.py | 268 ++++++++++++++++++ 9 files changed, 827 insertions(+), 4 deletions(-) create mode 100644 tests/api/test_admin_router.py create mode 100644 tests/conftest.py create mode 100644 tests/integration/test_user_repo.py create mode 100644 tests/unit/test_admin_service.py diff --git a/docker-compose.yaml b/docker-compose.yaml index e1f8ec4..4754bcf 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -21,6 +21,25 @@ services: timeout: 5s retries: 5 + db_test: + image: postgres:15-alpine + container_name: gitlab-db-test + restart: always + + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-mysecretpassword} + POSTGRES_DB: test_db + + ports: + - "5433:5432" + + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d test_db"] + interval: 10s + timeout: 5s + retries: 5 + backend: container_name: gitlab-backend diff --git a/pyproject.toml b/pyproject.toml index dc4ed4b..942a4e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,11 @@ dependencies = [ "python-jose[cryptography]>=3.3.0", "bcrypt==4.0.1", "cryptography>=42.0.0", + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "httpx>=0.27.0", + "greenlet>=3.2.4", + "pytest-mock>=3.15.1", ] [tool.ruff] @@ -42,6 +47,7 @@ fixable = ["ALL"] [tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401"] "alembic/versions/*" = ["ALL"] +"tests/*" = ["S101", "S105", "S106", "S107"] [tool.ruff.lint.mccabe] max-complexity = 10 @@ -50,6 +56,12 @@ max-complexity = 10 combine-as-imports = true known-first-party = ["app"] +[tool.pytest.ini_options] +pythonpath = [ + ".", "src" +] +asyncio_mode = "auto" + [dependency-groups] dev = [ "ruff>=0.14.1", diff --git a/src/application/services/admin_service.py b/src/application/services/admin_service.py index 16b2f26..35415a3 100644 --- a/src/application/services/admin_service.py +++ b/src/application/services/admin_service.py @@ -52,7 +52,8 @@ async def create_new_role(self, role_create: RoleCreate) -> DomainRole: existing_role = await self.role_repo.get_by_name(role_create.name) if existing_role: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="Role already exists" + status_code=status.HTTP_400_BAD_REQUEST, + detail="Role already exists" ) new_role = DomainRole( @@ -64,7 +65,8 @@ async def create_new_role(self, role_create: RoleCreate) -> DomainRole: created_role = await self.role_repo.create(new_role) if not created_role: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="Error creating a new role" + status_code=status.HTTP_400_BAD_REQUEST, + detail="Error creating a new role" ) return created_role diff --git a/src/core/settings.py b/src/core/settings.py index 06e4716..6b6ff78 100644 --- a/src/core/settings.py +++ b/src/core/settings.py @@ -10,6 +10,7 @@ class Settings(BaseSettings): VERSION: str = "0.1.0" DATABASE_URL: SecretStr + TEST_DATABASE_URL: SecretStr SECRET_KEY: SecretStr ENCRYPTION_KEY: SecretStr diff --git a/src/infrastructure/db/repositories/sqlalchemy_user_repo.py b/src/infrastructure/db/repositories/sqlalchemy_user_repo.py index 32d1984..397170c 100644 --- a/src/infrastructure/db/repositories/sqlalchemy_user_repo.py +++ b/src/infrastructure/db/repositories/sqlalchemy_user_repo.py @@ -87,7 +87,11 @@ async def update(self, user: DomainUser) -> Optional[DomainUser]: orm_user.role = user.role orm_user.hashed_password = user.hashed_password - await self.session.flush() - await self.session.refresh(orm_user) + try: + await self.session.flush() + await self.session.refresh(orm_user) + except IntegrityError: + await self.session.rollback() + return None return DomainUser.model_validate(orm_user) diff --git a/tests/api/test_admin_router.py b/tests/api/test_admin_router.py new file mode 100644 index 0000000..f2cfd09 --- /dev/null +++ b/tests/api/test_admin_router.py @@ -0,0 +1,237 @@ +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +import pytest_asyncio +from fastapi import HTTPException, status +from fastapi.testclient import TestClient +from httpx import ASGITransport, AsyncClient + +from src.api.dependencies import get_admin_service, get_current_admin_user +from src.application.services.admin_service import AdminService +from src.domain.models.user import Role as DomainRole, User as DomainUser +from src.main import app + +client = TestClient(app) + + +BASE_URL = "/v1/admin" + + +@pytest_asyncio.fixture(scope="function") +async def ac(): + """Create AsyncClient for tests.""" + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +@pytest_asyncio.fixture +def mock_admin_service(): + """Create mock for AdminSevice.""" + service = AsyncMock(spec=AdminService) + return service + + +@pytest_asyncio.fixture(autouse=True) +def override_dependencies(mock_admin_service): + """Override dependencies for AdminService's mock.""" + app.dependency_overrides[get_current_admin_user] = lambda: DomainUser( + id=uuid4(), + username="admin", + email="admin@test.com", + role="admin", + hashed_password="hash" + ) + + app.dependency_overrides[get_admin_service] = lambda: mock_admin_service + + yield + + app.dependency_overrides = {} + + +@pytest.mark.asyncio +async def test_get_all_users_not_empty(ac, mock_admin_service): + """Test that endpoint returns a list of Users when there is a valid data.""" + mock_users_list = [ + DomainUser( + id=uuid4(), + username="user1", + email="u1@t.com", + role="user", + hashed_password=".." + ), + DomainUser( + id=uuid4(), + username="user2", + email="u2@t.com", + role="user", + hashed_password=".." + ), + ] + mock_admin_service.get_all_users.return_value = mock_users_list + + response = await ac.get(f"{BASE_URL}/users") + + mock_admin_service.get_all_users.assert_called_once() + assert response.status_code == 200 + + data = response.json() + assert len(data) == 2 + assert data[0]["username"] == "user1" + assert data[0]["role"] == "user" + assert data[1]["username"] == "user2" + assert data[1]["role"] == "user" + + +@pytest.mark.asyncio +async def test_get_all_users_empty(ac, mock_admin_service): + """Test that endpoint returns an empty list when there is no data.""" + mock_admin_service.get_all_users.return_value = [] + + response = await ac.get(f"{BASE_URL}/users") + + mock_admin_service.get_all_users.assert_called_once() + assert response.status_code == 200 + + data = response.json() + assert data == [] + + +@pytest.mark.asyncio +async def test_get_all_roles_not_empty(ac, mock_admin_service): + """Test that endpoint returns a list of Role when there is a valid data.""" + mock_roles_list = [ + DomainRole(id=uuid4(), name="custom_role1", permissions=["perm1"]), + DomainRole(id=uuid4(), name="custom_role2", permissions=["perm2"]), + ] + mock_admin_service.get_all_roles.return_value = mock_roles_list + + response = await ac.get(f"{BASE_URL}/roles") + + mock_admin_service.get_all_roles.assert_called_once() + assert response.status_code == 200 + + data = response.json() + assert len(data) == 2 + assert data[0]["name"] == "custom_role1" + assert data[0]["permissions"] == ["perm1"] + assert data[1]["name"] == "custom_role2" + assert data[1]["permissions"] == ["perm2"] + + +@pytest.mark.asyncio +async def test_get_all_roles_empty(ac, mock_admin_service): + """Test that endpoint returns an empty list when there is no data.""" + mock_admin_service.get_all_roles.return_value = [] + + response = await ac.get(f"{BASE_URL}/roles") + + mock_admin_service.get_all_roles.assert_called_once() + assert response.status_code == 200 + + data = response.json() + assert data == [] + + +@pytest.mark.asyncio +async def test_update_user_role_success(ac, mock_admin_service): + """Test that endpoint returns User with updated fields when there is a valid data.""" + new_role = "new_role" + user_id = uuid4() + mock_admin_service.update_user_role.return_value = DomainUser( + id=user_id, + username="test_username", + email="test_email@test.com", + role=new_role, + hashed_password="test_hash" + ) + + payload = {"role": new_role} + response = await ac.put(f"{BASE_URL}/users/{user_id}/role", json=payload) + + assert response.status_code == 200 + + data = response.json() + assert data["id"] == str(user_id) + assert data["role"] == new_role + + mock_admin_service.update_user_role.assert_called_once_with(user_id, new_role) + + +@pytest.mark.asyncio +async def test_update_user_role_invalid_body(ac, mock_admin_service): + """Test that endpoint returns error when there is no valid data.""" + user_id = uuid4() + + response = await ac.put(f"{BASE_URL}/users/{user_id}/role", json={}) + assert response.status_code == 422 + + response = await ac.put(f"{BASE_URL}/users/{user_id}/role", json={"wrong_field": "admin"}) + assert response.status_code == 422 + + mock_admin_service.update_user_role.assert_not_called() + + +@pytest.mark.asyncio +async def test_create_new_role_success(ac, mock_admin_service): + """Test that endpoint returns new Role with when there is a valid data.""" + payload = { + "name": "super_editor", + "permissions": ["test_permissions"] + } + + role_response = DomainRole( + id=uuid4(), + name=payload["name"], + permissions=payload["permissions"] + ) + mock_admin_service.create_new_role.return_value = role_response + + response = await ac.post(f"{BASE_URL}/roles", json=payload) + + assert response.status_code == 201 + + data = response.json() + assert data["name"] == payload["name"] + assert data["permissions"] == payload["permissions"] + assert "id" in data + + mock_admin_service.create_new_role.assert_called_once() + + args, _ = mock_admin_service.create_new_role.call_args + assert args[0].name == payload["name"] + + +@pytest.mark.asyncio +async def test_create_new_role_invalid_body(ac, mock_admin_service): + """Test that endpoint returns error when there is no valid data.""" + payload = { + "permissions": ["read"] + } + + response = await ac.post(f"{BASE_URL}/roles", json=payload) + assert response.status_code == 422 + + + response = await ac.post(f"{BASE_URL}/roles", json={}) + assert response.status_code == 422 + + mock_admin_service.create_new_role.assert_not_called() + + +@pytest.mark.asyncio +async def test_create_new_role_already_exists(ac, mock_admin_service): + """Test that endpoint returns error when Role already exists.""" + payload = {"name": "existing_role", "permissions": []} + + mock_admin_service.create_new_role.side_effect = HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Role already exists" + ) + + response = await ac.post(f"{BASE_URL}/roles", json=payload) + + assert response.status_code == 400 + + mock_admin_service.create_new_role.assert_called_once() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9af9759 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,32 @@ +import pytest_asyncio +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from src.core.settings import settings +from src.infrastructure.db.models.base import Base + +TEST_DATABASE_URL = settings.TEST_DATABASE_URL.get_secret_value() + +@pytest_asyncio.fixture(scope="function") +async def db_engine(): + """Create an async engine for tests.""" + engine = create_async_engine(TEST_DATABASE_URL, echo=False) + + yield engine + + await engine.dispose() + +@pytest_asyncio.fixture(scope="function") +async def session(db_engine): + """Create an async session for tests.""" + async with db_engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + + async_session_factory = async_sessionmaker( + db_engine, + class_=AsyncSession, + expire_on_commit=False + ) + + async with async_session_factory() as session: + yield session diff --git a/tests/integration/test_user_repo.py b/tests/integration/test_user_repo.py new file mode 100644 index 0000000..1c82d3c --- /dev/null +++ b/tests/integration/test_user_repo.py @@ -0,0 +1,248 @@ +from uuid import uuid4 + +import pytest +import pytest_asyncio + +from src.domain.models.user import User as DomainUser +from src.infrastructure.db.repositories.sqlalchemy_user_repo import SqlAlchemyUserRepository + + +@pytest.fixture(scope="function") +def repo(session): + """Return SqlAlchemyUserRepository's fixture for tests.""" + return SqlAlchemyUserRepository(session) + + +@pytest_asyncio.fixture(scope="function") +async def user_factory(repo): + """Create User for tests. + + If specific values are not provided, factory creates a user with unique values. + """ + + async def _create_user( + username: str = "default_user", + email: str = "default@test.com", + role: str = "user", + hashed_password: str = "secret_hash" + ) -> DomainUser: + + unique_suffix = str(uuid4()) + + if username == "default_user": + username = f"user_{unique_suffix}" + if email == "default@test.com": + email = f"user_{unique_suffix}@test.com" + + new_user_data = DomainUser( + id=uuid4(), + username=username, + email=email, + role=role, + hashed_password=hashed_password + ) + + return await repo.create(new_user_data) + + return _create_user + + +@pytest.mark.asyncio +async def test_create_user_success(user_factory): + """Test that User is created.""" + username="test_username1" + email="test_email1@test.com" + role="user" + hashed_password="test_hash1" + + created_user = await user_factory( + username=username, + email=email, + role=role, + hashed_password=hashed_password + ) + + assert created_user.id is not None + assert created_user.username == username + assert created_user.email == email + assert created_user.role == role + assert created_user.hashed_password == hashed_password + + +@pytest.mark.asyncio +async def test_create_the_same_email_twice(repo, user_factory): + """Test that User is not created if email is already used.""" + first_user = await user_factory(email="integ1@test.com") + + assert first_user.id is not None + + second_user = await user_factory(email="integ1@test.com") + + assert second_user is None + + +@pytest.mark.asyncio +async def test_create_the_same_username_twice(repo, user_factory): + """Test that User is not created if username is already used.""" + first_user = await user_factory(username="integ_test_name1") + + assert first_user.id is not None + + second_user = await user_factory(username="integ_test_name1") + + assert second_user is None + + +@pytest.mark.asyncio +async def test_create_and_get_user_by_email_success(repo, user_factory): + """Test that existing User can be extracted by email.""" + created_user = await user_factory(email="integ@test.com") + + assert created_user.id is not None + + fetched_user = await repo.get_by_email("integ@test.com") + + assert fetched_user == created_user + + +@pytest.mark.asyncio +async def test_create_and_get_user_by_email_not_found(repo, user_factory): + """Test that User cannot be extracted by email if it doesnt exist.""" + created_user = await user_factory() + + assert created_user.id is not None + + fetched_user = await repo.get_by_email("ghost@test.com") + + assert fetched_user is None + + +@pytest.mark.asyncio +async def test_create_and_get_user_by_username_success(repo, user_factory): + """Test that existing User can be extracted by username.""" + created_user = await user_factory(username="integ_test_name1") + + assert created_user.id is not None + + fetched_user = await repo.get_by_username(username="integ_test_name1") + + assert fetched_user == created_user + + +@pytest.mark.asyncio +async def test_create_and_get_user_by_username_not_found(repo, user_factory): + """Test that User cannot be extracted by username if it doesnt exist.""" + created_user = await user_factory() + + assert created_user.id is not None + + fetched_user = await repo.get_by_username(username="ghost") + + assert fetched_user is None + + +@pytest.mark.asyncio +async def test_create_and_get_user_by_id_success(repo, user_factory): + """Test that existing User can be extracted by id.""" + created_user = await user_factory(username="test_by_id") + + assert created_user.id is not None + + fetched_user = await repo.get_by_id(user_id=created_user.id) + + assert fetched_user == created_user + + +@pytest.mark.asyncio +async def test_create_and_get_user_by_id_not_found(repo, user_factory): + """Test that User cannot be extracted by id if it doesnt exist.""" + created_user = await user_factory(username="test_by_id") + + assert created_user.id is not None + + fetched_user = await repo.get_by_id(user_id=uuid4()) + + assert fetched_user is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("n_users", [1, 2, 3]) +async def test_create_and_get_all_users_success(repo, user_factory, n_users): + """Test that all existing Users can be extracted.""" + created_users = [] + for _ in range(n_users): + created_users.append( + await user_factory() + ) + + result = await repo.get_all_users() + + assert result == created_users + + +@pytest.mark.asyncio +async def test_create_and_get_all_users_empty(repo): + """Test that an empty list is returned if there are no Users.""" + result = await repo.get_all_users() + + assert result == [] + + +@pytest.mark.asyncio +async def test_update_user_success(repo, user_factory): + """Test that all User's fields can be updated.""" + created_user = await user_factory() + + update_data = DomainUser( + id=created_user.id, + username="new_username", + email="new_email@test.com", + role="admin", + hashed_password="new_hash" + ) + + updated_user = await repo.update(update_data) + + assert updated_user == update_data + + updated_user_from_db = await repo.get_by_id(updated_user.id) + + assert updated_user_from_db == updated_user # check that db contains new values + + +@pytest.mark.asyncio +async def test_update_user_not_found(repo, user_factory): + """Test that there won't be any updates if user doen't exist.""" + await user_factory() + + update_data = DomainUser( + id=uuid4(), + username="new_username", + email="new_email@test.com", + role="admin", + hashed_password="new_hash" + ) + + updated_user = await repo.update(update_data) + + assert updated_user is None + + +@pytest.mark.asyncio +async def test_update_user_value_already_used(repo, user_factory): + """Test that values that are already used cannot be provided in update operations.""" + created_user1 = await user_factory(username="username1") + + await user_factory(username="username2") + + update_data = DomainUser( + id=created_user1.id, + username="username2", # already used! + email="new_email@test.com", + role="admin", + hashed_password="new_hash" + ) + + updated_user = await repo.update(update_data) + + assert updated_user is None diff --git a/tests/unit/test_admin_service.py b/tests/unit/test_admin_service.py new file mode 100644 index 0000000..9ba2c71 --- /dev/null +++ b/tests/unit/test_admin_service.py @@ -0,0 +1,268 @@ +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from fastapi import HTTPException + +from src.api.schemas.admin import RoleCreate +from src.application.services.admin_service import AdminService +from src.domain.models.user import Role as DomainRole, User as DomainUser + + +@pytest.fixture(scope="function") +def mock_user_repo(): + """Create AsyncMock for user_repo.""" + return AsyncMock() + + +@pytest.fixture(scope="function") +def mock_role_repo(): + """Create AsyncMock for role_repo.""" + return AsyncMock() + + +@pytest.mark.asyncio +async def test_get_all_users_not_empty(mock_user_repo, mock_role_repo): + """Test that service returns list of Users when db contains valid data.""" + existing_users = [ + DomainUser( + id=uuid4(), + username="test1", + email="test1@test.com", + role="user", + hashed_password="..." + ), + DomainUser( + id=uuid4(), + username="test2", + email="test2@test.com", + role="user", + hashed_password="..." + ) + ] + + mock_user_repo.get_all_users.return_value = existing_users + service = AdminService(user_repo=mock_user_repo, role_repo=mock_role_repo) + + result = await service.get_all_users() + + mock_user_repo.get_all_users.assert_called_once() + assert result == existing_users + + +@pytest.mark.asyncio +async def test_get_all_users_empty(mock_user_repo, mock_role_repo): + """Test that service returns empty list when db is empty.""" + mock_user_repo.get_all_users.return_value = [] + + mock_user_repo.get_all_users.return_value = [] + service = AdminService(user_repo=mock_user_repo, role_repo=mock_role_repo) + + result = await service.get_all_users() + + mock_user_repo.get_all_users.assert_called_once() + assert result == [] + + +@pytest.mark.asyncio +async def test_get_all_roles_not_empty(mock_user_repo, mock_role_repo): + """Test that service returns list of Roles when db contains valid data.""" + roles = [ + DomainRole(id=uuid4(), name="test_role1", permissions=["read:repo:project_x"]), + DomainRole(id=uuid4(), name="test_role2", permissions=["chat:use"]) + ] + mock_role_repo.get_all_roles.return_value = roles + + service = AdminService(user_repo=mock_user_repo, role_repo=mock_role_repo) + + result = await service.get_all_roles() + + mock_role_repo.get_all_roles.assert_called_once() + assert result == roles + + +@pytest.mark.asyncio +async def test_get_all_roles_empty(mock_user_repo, mock_role_repo): + """Test that service returns empty list when db is empty.""" + mock_role_repo.get_all_roles.return_value = [] + + service = AdminService(user_repo=mock_user_repo, role_repo=mock_role_repo) + + result = await service.get_all_roles() + + mock_role_repo.get_all_roles.assert_called_once() + assert result == [] + + +@pytest.mark.asyncio +async def test_update_user_role_success(mock_user_repo, mock_role_repo): + """Test that service updates Role with valid data when User exists.""" + user_id = uuid4() + existing_user = DomainUser( + id=user_id, + username="test", + email="test@test.com", + role="user", + hashed_password="..." + ) + mock_user_repo.get_by_id.return_value = existing_user + + role_name = "admin" + mock_role_repo.get_by_name.return_value = DomainRole( + id=uuid4(), name="admin", permissions=[] + ) + + updated_user_copy = existing_user.model_copy(update={"role": role_name}) + mock_user_repo.update.return_value = updated_user_copy + service = AdminService(user_repo=mock_user_repo, role_repo=mock_role_repo) + + result = await service.update_user_role(user_id=user_id, role_name=role_name) + + mock_user_repo.get_by_id.assert_called_once_with(user_id) + mock_role_repo.get_by_name.assert_called_once_with(role_name) + + called_user_obj = mock_user_repo.update.call_args[0][0] + assert called_user_obj.role == role_name + + assert result.role == role_name + + +@pytest.mark.asyncio +async def test_update_user_role_user_doesnt_exist(mock_user_repo, mock_role_repo): + """Test that service doesn't update Role when User doesn't exist.""" + mock_user_repo.get_by_id.return_value = None + + service = AdminService(user_repo=mock_user_repo, role_repo=mock_role_repo) + + user_id = uuid4() + with pytest.raises(HTTPException) as exc: + await service.update_user_role(user_id, "admin") + + mock_user_repo.get_by_id.assert_called_once() + mock_user_repo.get_by_id.assert_called_once_with(user_id) + assert exc.value.status_code == 400 + assert "no such user" in exc.value.detail + + +@pytest.mark.asyncio +async def test_update_user_role_role_doesnt_exist(mock_user_repo, mock_role_repo): + """Test that service doesn't update Role when Role doesn't exist.""" + user_id = uuid4() + existing_user = DomainUser( + id=user_id, + username="test", + email="test@test.com", + role="user", + hashed_password="..." + ) + mock_user_repo.get_by_id.return_value = existing_user + + mock_role_repo.get_by_name.return_value = None + + service = AdminService(user_repo=mock_user_repo, role_repo=mock_role_repo) + + role_name = "admin" + with pytest.raises(HTTPException) as exc: + await service.update_user_role(user_id, role_name) + + mock_user_repo.get_by_id.assert_called_once() + mock_user_repo.get_by_id.assert_called_once_with(user_id) + mock_role_repo.get_by_name.assert_called_once() + mock_role_repo.get_by_name.assert_called_once_with(role_name) + assert exc.value.status_code == 400 + assert "no such role" in exc.value.detail + + +@pytest.mark.asyncio +async def test_create_new_role_success( + mock_user_repo, + mock_role_repo, + mocker +): + """Test that new Role is created when there is valid data.""" + role_name = "admin" + permissions = ["read:repo:project_x", "chat:use"] + fixed_uuid = uuid4() + new_role = DomainRole( + id=fixed_uuid, + name=role_name, + permissions=permissions + ) + + mocker.patch("src.infrastructure.db.models.base.uuid.uuid4", return_value=fixed_uuid) + mock_role_repo.get_by_name.return_value = None + mock_role_repo.create.return_value = new_role + + service = AdminService(user_repo=mock_user_repo, role_repo=mock_role_repo) + + result = await service.create_new_role( + RoleCreate( + name=role_name, + permissions=permissions + ) + ) + + mock_role_repo.get_by_name.assert_called_once() + mock_role_repo.get_by_name.assert_called_once_with(role_name) + mock_role_repo.create.assert_called_once() + assert result == new_role + + +@pytest.mark.asyncio +async def test_create_new_role_role_already_exists( + mock_user_repo, + mock_role_repo +): + """Test that new Role isn't created when the same Role already exists.""" + role_name = "admin" + permissions = ["read:repo:project_x", "chat:use"] + + mock_role_repo.get_by_name.return_value = DomainRole( + id=uuid4(), + name=role_name, + permissions=permissions + ) + + service = AdminService(user_repo=mock_user_repo, role_repo=mock_role_repo) + + with pytest.raises(HTTPException) as exc: + await service.create_new_role( + RoleCreate( + name=role_name, + permissions=permissions + ) + ) + + mock_role_repo.get_by_name.assert_called_once() + mock_role_repo.get_by_name.assert_called_once_with(role_name) + assert exc.value.status_code == 400 + assert "Role already exists" in exc.value.detail + + +@pytest.mark.asyncio +async def test_create_new_role_error( + mock_user_repo, + mock_role_repo +): + """Test that new Role isn't created when there is an error.""" + role_name = "admin" + permissions = ["read:repo:project_x", "chat:use"] + + mock_role_repo.get_by_name.return_value = None + mock_role_repo.create.return_value = None + + service = AdminService(user_repo=mock_user_repo, role_repo=mock_role_repo) + + with pytest.raises(HTTPException) as exc: + await service.create_new_role( + RoleCreate( + name=role_name, + permissions=permissions + ) + ) + + mock_role_repo.get_by_name.assert_called_once() + mock_role_repo.get_by_name.assert_called_once_with(role_name) + mock_role_repo.create.assert_called_once() + assert exc.value.status_code == 400 + assert "Error creating a new role" in exc.value.detail From 806cc4c51c5d1512fd35ce9819dabb85f843cbfa Mon Sep 17 00:00:00 2001 From: sardor014 Date: Sat, 27 Dec 2025 14:58:29 +0300 Subject: [PATCH 02/13] feat(backend): add tests for auth router and auth service --- tests/api/test_auth_router.py | 164 ++++++++++++++++++++++++ tests/unit/test_admin_service.py | 2 +- tests/unit/test_auth_service.py | 207 +++++++++++++++++++++++++++++++ 3 files changed, 372 insertions(+), 1 deletion(-) create mode 100644 tests/api/test_auth_router.py create mode 100644 tests/unit/test_auth_service.py diff --git a/tests/api/test_auth_router.py b/tests/api/test_auth_router.py new file mode 100644 index 0000000..6c9ea01 --- /dev/null +++ b/tests/api/test_auth_router.py @@ -0,0 +1,164 @@ +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +import pytest_asyncio +from fastapi import HTTPException, status +from fastapi.testclient import TestClient +from httpx import ASGITransport, AsyncClient + +from src.api.dependencies import ( + get_auth_service, + get_current_user, +) +from src.application.services.auth_service import AuthService +from src.domain.models.user import User as DomainUser +from src.main import app + +client = TestClient(app) + + +BASE_URL = "/v1/auth" + + +@pytest_asyncio.fixture(scope="function") +async def ac(): + """Create AsyncClient for tests.""" + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +@pytest_asyncio.fixture +def mock_auth_service(): + """Create mock for AuthSevice.""" + service = AsyncMock(spec=AuthService) + return service + + +@pytest_asyncio.fixture(autouse=True) +def override_dependencies(mock_auth_service): + """Override dependencies for AuthService's mock.""" + app.dependency_overrides[get_current_user] = lambda: DomainUser( + id=uuid4(), + username="user", + email="user@test.com", + role="user", + hashed_password="hash" + ) + + app.dependency_overrides[get_auth_service] = lambda: mock_auth_service + + yield + + app.dependency_overrides = {} + +@pytest.mark.asyncio +async def test_register_user_success(ac, mock_auth_service): + """Test that endpoint returns register_data that have been provided.""" + register_data = { + "username": "test_username", + "email": "test@test.com", + "password": "test_password", + } + + expected_user = DomainUser( + id=uuid4(), + username=register_data["username"], + email=register_data["email"], + role="user", + hashed_password="test_hash" + ) + mock_auth_service.register_new_user.return_value = expected_user + + response = await ac.post(f"{BASE_URL}/register", json=register_data) + + assert response.status_code == 200 + + mock_auth_service.register_new_user.assert_called_once() + + data = response.json() + assert data["username"] == register_data["username"] + assert data["email"] == register_data["email"] + assert "id" in data + + +@pytest.mark.asyncio +async def test_login_for_access_token_success(ac, mock_auth_service): + """Test that endpoint returns JWT-token and its type if valid login_data provided.""" + login_data = { + "username": "test_username", + "password": "test_password" + } + + token_response = { + "access_token": "test_jwt_token", + "token_type": "bearer" + } + mock_auth_service.authenticate_user.return_value = token_response + + response = await ac.post(f"{BASE_URL}/token", data=login_data) + + assert response.status_code == 200 + + mock_auth_service.authenticate_user.assert_called_once_with( + username="test_username", password="test_password" + ) + + data = response.json() + assert data["access_token"] == "test_jwt_token" + assert data["token_type"] == "bearer" + + +@pytest.mark.asyncio +async def test_login_for_access_token_not_all_fields(ac, mock_auth_service): + """Test that endpoint raises error when not all login data fields provided.""" + token_response = { + "access_token": "test_jwt_token", + "token_type": "bearer" + } + mock_auth_service.authenticate_user.return_value = token_response + + login_data = { + "password": "test_password" + } + + response = await ac.post(f"{BASE_URL}/token", data=login_data) + + assert response.status_code == 422 + + login_data = { + "username": "test_username" + } + + response = await ac.post(f"{BASE_URL}/token", data=login_data) + + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_login_wrong_credentials(ac, mock_auth_service): + """Test that endpoint raises error when login data is incorrect.""" + mock_auth_service.authenticate_user.side_effect = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password" + ) + + login_data = { + "username": "test_username", + "password": "test_password" + } + response = await ac.post(f"{BASE_URL}/token", data=login_data) + + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_read_users_me_success(ac): + """Test that endpoint returns valid data of current user.""" + response = await ac.get(f"{BASE_URL}/me") + + assert response.status_code == 200 + data = response.json() + + assert data["username"] == "user" # from override fixture + assert data["email"] == "user@test.com" diff --git a/tests/unit/test_admin_service.py b/tests/unit/test_admin_service.py index 9ba2c71..17c3b88 100644 --- a/tests/unit/test_admin_service.py +++ b/tests/unit/test_admin_service.py @@ -189,7 +189,7 @@ async def test_create_new_role_success( permissions=permissions ) - mocker.patch("src.infrastructure.db.models.base.uuid.uuid4", return_value=fixed_uuid) + mocker.patch("uuid.uuid4", return_value=fixed_uuid) mock_role_repo.get_by_name.return_value = None mock_role_repo.create.return_value = new_role diff --git a/tests/unit/test_auth_service.py b/tests/unit/test_auth_service.py new file mode 100644 index 0000000..a0f1a0c --- /dev/null +++ b/tests/unit/test_auth_service.py @@ -0,0 +1,207 @@ +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from fastapi import HTTPException + +from src.api.schemas.auth import UserRegistration +from src.application.services.auth_service import AuthService +from src.domain.models.user import User as DomainUser + + +@pytest.fixture(scope="function") +def mock_user_repo(): + """Create AsyncMock for user_repo.""" + return AsyncMock() + + +@pytest.mark.asyncio +async def test_register_new_user_success(mock_user_repo, mocker): + """Test that new service returns created User if username and email are unique.""" + fixed_uuid = str(uuid4()) + hashed_password = "test_hash" + mocker.patch("passlib.context.CryptContext.hash", return_value=hashed_password) + mocker.patch("src.infrastructure.db.models.base.uuid.uuid4", return_value=fixed_uuid) + + mock_user_repo.get_by_username.return_value = None + mock_user_repo.get_by_email.return_value = None + new_user = UserRegistration( + username="test_username", + email="test@test.com", + password="test_password" + ) + created_user = DomainUser( + id=fixed_uuid, + username="test_username", + email="test@test.com", + role="user", + hashed_password=hashed_password + ) + mock_user_repo.create.return_value = created_user + + service = AuthService(mock_user_repo) + + result = await service.register_new_user(new_user) + + mock_user_repo.get_by_username.assert_called_once_with("test_username") + mock_user_repo.get_by_email.assert_called_once_with("test@test.com") + assert result == created_user + + +@pytest.mark.asyncio +async def test_resgister_new_user_username_already_exist(mock_user_repo): + """Test that new service raises error when user with the same username already exists.""" + new_user = UserRegistration( + username="test_username", + email="test@test.com", + password="test_password" + ) + + mock_user_repo.get_by_username.return_value = DomainUser( + id=str(uuid4()), + username="test_username", + email="different@test.com", + role="user", + hashed_password="test_hash" + ) + + service = AuthService(mock_user_repo) + + with pytest.raises(HTTPException) as exc: + await service.register_new_user(new_user) + + mock_user_repo.get_by_email.assert_not_called() + + assert exc.value.status_code == 400 + assert "Username is already registered" in exc.value.detail + + +@pytest.mark.asyncio +async def test_resgister_new_user_email_already_exist(mock_user_repo): + """Test that new service raises error when user with the same email already exists.""" + new_user = UserRegistration( + username="test_username", + email="test@test.com", + password="test_password" + ) + + mock_user_repo.get_by_username.return_value = None + mock_user_repo.get_by_email.return_value = DomainUser( + id=str(uuid4()), + username="different_username", + email="test@test.com", + role="user", + hashed_password="test_hash" + ) + + service = AuthService(mock_user_repo) + + with pytest.raises(HTTPException) as exc: + await service.register_new_user(new_user) + + + assert exc.value.status_code == 400 + assert "Email is already registered" in exc.value.detail + + +@pytest.mark.asyncio +async def test_resgister_new_user_error_create(mock_user_repo): + """Test that new service raises error when registration fails.""" + new_user = UserRegistration( + username="test_username", + email="test@test.com", + password="test_password" + ) + + mock_user_repo.get_by_username.return_value = None + mock_user_repo.get_by_email.return_value = None + mock_user_repo.create.return_value = None + + service = AuthService(mock_user_repo) + + with pytest.raises(HTTPException) as exc: + await service.register_new_user(new_user) + + + assert exc.value.status_code == 400 + assert "User could not be created" in exc.value.detail + + +@pytest.mark.asyncio +async def test_authenticate_user_success(mock_user_repo, mocker): + """Test that new service returns token and its type when username and password are valid.""" + username = "test_username" + password = "test_password" + hash_pass = "test_password_hashed" + mocker.patch("passlib.context.CryptContext.hash", return_value=hash_pass) + mocker.patch("passlib.context.CryptContext.verify", return_value=True) + user_in_db = DomainUser( + id=str(uuid4()), + username=username, + email="test@test.com", + role="user", + hashed_password=hash_pass + ) + mock_user_repo.get_by_username.return_value = user_in_db + + service = AuthService(mock_user_repo) + + result = await service.authenticate_user( + username=username, + password=password + ) + + mock_user_repo.get_by_username.assert_called_once_with(username) + assert "access_token" in result + assert result["token_type"] == "bearer" + assert len(result) == 2 + + +@pytest.mark.asyncio +async def test_authenticate_user_user_not_found(mock_user_repo): + """Test that service raises error when User is not found.""" + mock_user_repo.get_by_username.return_value = None + + service = AuthService(mock_user_repo) + + with pytest.raises(HTTPException) as exc: + await service.authenticate_user( + username="test_username", + password="test_password" + ) + + mock_user_repo.get_by_username.assert_called_once_with("test_username") + + assert exc.value.status_code == 401 + assert "Incorrect username" in exc.value.detail + + +@pytest.mark.asyncio +async def test_authenticate_user_incorrect_password(mock_user_repo, mocker): + """Test that service raises error when password is not correct.""" + username = "test_username" + password = "test_password" + hash_pass = "test_password_hashed" + mocker.patch("passlib.context.CryptContext.hash", return_value="incorrect_hash") + mocker.patch("passlib.context.CryptContext.verify", return_value=False) + user_in_db = DomainUser( + id=str(uuid4()), + username=username, + email="test@test.com", + role="user", + hashed_password=hash_pass + ) + mock_user_repo.get_by_username.return_value = user_in_db + + service = AuthService(mock_user_repo) + + with pytest.raises(HTTPException) as exc: + await service.authenticate_user( + username=username, + password=password + ) + + mock_user_repo.get_by_username.assert_called_once_with(username) + + assert exc.value.status_code == 401 + assert "Incorrect username or password" in exc.value.detail From d64c38290213a5fface8ab15b059a2250bfd248f Mon Sep 17 00:00:00 2001 From: sardor014 Date: Sat, 27 Dec 2025 20:24:39 +0300 Subject: [PATCH 03/13] feat(backend): add tests for chat router and chat service --- src/application/services/chat_service.py | 2 +- tests/api/test_chat_router.py | 228 +++++++++++++++++++++++ tests/integration/test_chat_repo.py | 179 ++++++++++++++++++ tests/unit/test_chat_service.py | 209 +++++++++++++++++++++ 4 files changed, 617 insertions(+), 1 deletion(-) create mode 100644 tests/api/test_chat_router.py create mode 100644 tests/integration/test_chat_repo.py create mode 100644 tests/unit/test_chat_service.py diff --git a/src/application/services/chat_service.py b/src/application/services/chat_service.py index baa1a86..6c73306 100644 --- a/src/application/services/chat_service.py +++ b/src/application/services/chat_service.py @@ -17,7 +17,7 @@ def __init__( ): self.chat_repo = chat_repo - async def create_chat(self, owner_id: UUID4, title: str): + async def create_chat(self, owner_id: UUID4, title: str) -> Chat: """Create a new chat with specified title for user by their id.""" return await self.chat_repo.create_chat(owner_id, title) diff --git a/tests/api/test_chat_router.py b/tests/api/test_chat_router.py new file mode 100644 index 0000000..b5fe2d0 --- /dev/null +++ b/tests/api/test_chat_router.py @@ -0,0 +1,228 @@ +from datetime import datetime +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +import pytest_asyncio +from fastapi import HTTPException, status +from fastapi.testclient import TestClient +from httpx import ASGITransport, AsyncClient + +from src.api.dependencies import ( + get_chat_service, + get_current_user, +) +from src.application.services.chat_service import ChatService +from src.domain.models.chat import Chat as DomainChat, Message as DomainMessage +from src.domain.models.user import User as DomainUser +from src.main import app + +client = TestClient(app) + + +BASE_URL = "/v1/chat" + + +@pytest_asyncio.fixture(scope="function") +async def ac(): + """Create AsyncClient for tests.""" + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +@pytest_asyncio.fixture +def mock_chat_service(): + """Create mock for ChatSevice.""" + service = AsyncMock(spec=ChatService) + return service + + +@pytest.fixture +def mock_user(): + """Create mock for User.""" + return DomainUser( + id=uuid4(), + username="user", + email="user@test.com", + role="user", + hashed_password="hash" + ) + + +@pytest_asyncio.fixture(autouse=True) +def override_dependencies(mock_chat_service, mock_user): + """Override dependencies for ChatService's mock.""" + app.dependency_overrides[get_current_user] = lambda: mock_user + + app.dependency_overrides[get_chat_service] = lambda: mock_chat_service + + yield + + app.dependency_overrides = {} + + +@pytest.mark.asyncio +async def test_create_new_chat_success(ac, mock_chat_service, mock_user): + """Test that endpoint returns new Chat when valid title provided.""" + chat_id = uuid4() + chat_title = "test_title" + + payload = { + "title": chat_title + } + + mock_chat_service.create_chat.return_value = DomainChat( + id=chat_id, + title="test_title", + owner_id=mock_user.id, + created_at=datetime.now(), + messages=[] + ) + + response = await ac.post(f"{BASE_URL}/", json=payload) + + mock_chat_service.create_chat.assert_called_once_with( + owner_id=mock_user.id, title=chat_title + ) + assert response.status_code == 201 + + data = response.json() + assert data["title"] == "test_title" + assert data["owner_id"] == str(mock_user.id) + + +@pytest.mark.asyncio +async def test_create_new_chat_no_title(ac): + """Test that endpoint raises error when no title is provided.""" + response = await ac.post(f"{BASE_URL}/", json={}) # no title key in json + + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_get_user_chats_success(ac, mock_chat_service, mock_user): + """Test that endpoint returns all user's Chats.""" + chat1 = DomainChat( + id=uuid4(), + title="test_title1", + owner_id=mock_user.id, + created_at=datetime.now(), + messages=[] + ) + chat2 = DomainChat( + id=uuid4(), + title="test_title2", + owner_id=mock_user.id, + created_at=datetime.now(), + messages=[] + ) + + mock_chat_service.get_user_chats.return_value = [chat1, chat2] + + response = await ac.get(f"{BASE_URL}/") + + assert response.status_code == 200 + mock_chat_service.get_user_chats.assert_called_once_with(mock_user.id) + + data = response.json() + assert len(data) == 2 + assert data[0]["id"] == str(chat1.id) + assert data[0]["title"] == "test_title1" + assert data[1]["id"] == str(chat2.id) + assert data[1]["title"] == "test_title2" + + +@pytest.mark.asyncio +async def test_get_user_chats_empty(ac, mock_chat_service, mock_user): + """Test that endpoint returns empty list when there is no user's Chats.""" + mock_chat_service.get_user_chats.return_value = [] + + response = await ac.get(f"{BASE_URL}/") + + assert response.status_code == 200 + mock_chat_service.get_user_chats.assert_called_once_with(mock_user.id) + + assert response.json() == [] + + +@pytest.mark.asyncio +async def test_get_chat_history_success(ac, mock_chat_service, mock_user): + """Test that endpoint returns new Chat history.""" + chat_id = uuid4() + + message = DomainMessage( + id=uuid4(), + role="user", + content="test_content", + created_at=datetime.now() + ) + + chat_with_history = DomainChat( + id=chat_id, + title="test_title1", + owner_id=mock_user.id, + created_at=datetime.now(), + messages=[message] + ) + + mock_chat_service.get_chat_history.return_value = chat_with_history + + response = await ac.get(f"{BASE_URL}/{chat_id}") + + assert response.status_code == 200 + mock_chat_service.get_chat_history.assert_called_once_with( + user_id=mock_user.id, chat_id=chat_id + ) + + data = response.json() + assert data["id"] == str(chat_id) + assert len(data["messages"]) == 1 + assert data["messages"][0]["content"] == "test_content" + + +@pytest.mark.asyncio +async def test_get_chat_history_not_found(ac, mock_chat_service): + """Test that endpoint raises error when chat is not found.""" + chat_id = uuid4() + + mock_chat_service.get_chat_history.side_effect = HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Chat {chat_id} not found" + ) + + response = await ac.get(f"{BASE_URL}/{chat_id}") + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "not found" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_send_message_success(ac, mock_chat_service, mock_user): + """Test that endpoint returns the answer to the question (message) sent.""" + chat_id = uuid4() + question = "test_question" + + payload = {"content": question} + + assistant_response = DomainMessage( + id=uuid4(), + chat_id=chat_id, + role="assistant", + content="test_answer", + created_at=datetime.now() + ) + mock_chat_service.ask_question.return_value = assistant_response + + response = await ac.post(f"{BASE_URL}/{chat_id}/message", json=payload) + + assert response.status_code == 200 + mock_chat_service.ask_question.assert_called_once_with( + user_id=mock_user.id, + chat_id=chat_id, + question=question + ) + + data = response.json() + assert data["role"] == "assistant" + assert data["content"] == "test_answer" + diff --git a/tests/integration/test_chat_repo.py b/tests/integration/test_chat_repo.py new file mode 100644 index 0000000..cf8ea77 --- /dev/null +++ b/tests/integration/test_chat_repo.py @@ -0,0 +1,179 @@ +from uuid import uuid4 + +import pytest +import pytest_asyncio +from pydantic import UUID4 + +from src.domain.models.chat import Chat as DomainChat +from src.domain.models.user import User as DomainUser +from src.infrastructure.db.repositories.sqlalchemy_chat_repo import SqlAlchemyChatRepository +from src.infrastructure.db.repositories.sqlalchemy_user_repo import SqlAlchemyUserRepository + + +@pytest.fixture(scope="function") +def user_repo(session): + """Return SqlAlchemyUserRepository's fixture for tests.""" + return SqlAlchemyUserRepository(session) + + +@pytest.fixture(scope="function") +def chat_repo(session): + """Return SqlAlchemyChatRepository's fixture for tests.""" + return SqlAlchemyChatRepository(session) + + +@pytest_asyncio.fixture(scope="function") +async def user_factory(user_repo): + """Create User for tests. + + If specific values are not provided, factory creates a user with unique values. + """ + + async def _create_user( + username: str = "default_user", + email: str = "default@test.com", + role: str = "user", + hashed_password: str = "secret_hash" + ) -> DomainUser: + + unique_suffix = str(uuid4()) + + if username == "default_user": + username = f"user_{unique_suffix}" + if email == "default@test.com": + email = f"user_{unique_suffix}@test.com" + + new_user_data = DomainUser( + id=uuid4(), + username=username, + email=email, + role=role, + hashed_password=hashed_password + ) + + return await user_repo.create(new_user_data) + + return _create_user + + +@pytest_asyncio.fixture(scope="function") +async def test_user(user_factory): + """Create user in db.""" + user = await user_factory(username="fixed_user", email="fixed@test.com") + return user + + +@pytest_asyncio.fixture(scope="function") +async def chat_factory(chat_repo): + """Create Chat for tests. + + If specific values are not provided, factory creates a chat with unique values. + """ + + async def _create_chat( + title: str = "default_title", + owner_id: UUID4 = uuid4() + ) -> DomainChat: + + unique_suffix = str(uuid4()) + + if title == "default_title": + title = f"title_{unique_suffix}" + + return await chat_repo.create_chat(owner_id, title) + + return _create_chat + + +@pytest.mark.asyncio +async def test_create_chat_success(chat_factory, test_user): + """Test that Chat is created.""" + title = "test_title" + + created_chat = await chat_factory( + title=title, + owner_id=test_user.id + ) + + assert created_chat.id is not None + assert created_chat.title == title + assert created_chat.owner_id == test_user.id + + +@pytest.mark.asyncio +@pytest.mark.parametrize("n_chats", [1, 2]) +async def test_get_user_chats_success(chat_factory, chat_repo, test_user, n_chats): + """Test that all User's Chats can be extracted by user_id.""" + title = f"test_title_{n_chats}" + + for _ in range(n_chats): + await chat_factory( + title=title, + owner_id=test_user.id + ) + + all_chats = await chat_repo.get_user_chats(test_user.id) + + assert len(all_chats) == n_chats + for ind in range(n_chats): + assert all_chats[ind].id is not None + assert all_chats[ind].owner_id == test_user.id + assert all_chats[ind].title == f"test_title_{n_chats}" + + +@pytest.mark.asyncio +async def test_get_user_chats_empty_chat_list(chat_repo, test_user): + """Test that an empty list will be returned when there is no Chats.""" + all_chats = await chat_repo.get_user_chats(test_user.id) + + assert all_chats == [] + + +@pytest.mark.asyncio +async def test_add_message_success(chat_repo, chat_factory, test_user): + """Test that Message is added.""" + chat = await chat_factory(owner_id=test_user.id) + + role = "user" + content = "test_content" + + message = await chat_repo.add_message( + chat_id=chat.id, + role=role, + content=content, + sources=[] + ) + + assert message.id is not None + assert message.role == role + assert message.content == content + assert message.sources == [] + + +@pytest.mark.asyncio +async def test_get_chat_full_success(chat_repo, chat_factory, test_user): + """Test that Message is added and can be extracted from Chat history.""" + chat = await chat_factory(owner_id=test_user.id) + + await chat_repo.add_message(chat.id, "user", "question1") + await chat_repo.add_message(chat.id, "assistant", "answer1") + + full_chat = await chat_repo.get_chat_full(chat.id) + + assert full_chat is not None + assert full_chat.id == chat.id + + assert len(full_chat.messages) == 2 + + assert full_chat.messages[0].role == "user" + assert full_chat.messages[0].content == "question1" + assert full_chat.messages[1].role == "assistant" + assert full_chat.messages[1].content == "answer1" + + +@pytest.mark.asyncio +async def test_get_chat_full_chat_not_found(chat_repo): + """Test that nothing can be extracted from an unexisting Chat.""" + result = await chat_repo.get_chat_full(uuid4()) + + assert result is None diff --git a/tests/unit/test_chat_service.py b/tests/unit/test_chat_service.py new file mode 100644 index 0000000..a66f3e2 --- /dev/null +++ b/tests/unit/test_chat_service.py @@ -0,0 +1,209 @@ +from datetime import datetime +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from fastapi import HTTPException + +from src.application.services.chat_service import ChatService +from src.domain.models.chat import Chat as DomainChat, Message as DomainMessage + + +@pytest.fixture(scope="function") +def mock_chat_repo(): + """Create AsyncMock for chat_repo.""" + return AsyncMock() + + +@pytest.mark.asyncio +async def test_create_chat_success(mock_chat_repo): + """Test that Chat is created.""" + owner_id = uuid4() + title = "test_title" + create_time = datetime.now() + mock_chat_repo.create_chat.return_value = DomainChat( + id=uuid4(), + title=title, + owner_id=owner_id, + created_at=create_time + ) + + service = ChatService(mock_chat_repo) + + result = await service.create_chat(owner_id, title) + + mock_chat_repo.create_chat.assert_called_once_with(owner_id, title) + + assert result.title == title + assert result.owner_id == owner_id + assert result.created_at == create_time + assert result.messages == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("n_chats", [0, 1, 2]) +async def test_user_chats_success(mock_chat_repo, n_chats): + """Test that all User's Chats are returned.""" + user_id = str(uuid4()) + mock_chat_repo.get_user_chats.return_value = [ + DomainChat( + id=str(uuid4()), + title=f"test_title_no{n_chats}", + owner_id=user_id, + created_at=datetime.now() + ) + ] * n_chats + + service = ChatService(mock_chat_repo) + + result = await service.get_user_chats(user_id) + + mock_chat_repo.get_user_chats.assert_called_once_with(user_id) + + assert len(result) == n_chats + + +@pytest.mark.asyncio +async def test_get_chat_history_success(mock_chat_repo): + """Test that all Chat history is returned.""" + user_id = uuid4() + chat_id = uuid4() + create_time = datetime.now() + mock_chat_repo.get_chat_full.return_value = DomainChat( + id=chat_id, + title="test_title", + owner_id=user_id, + created_at=create_time + ) + + service = ChatService(mock_chat_repo) + + result = await service.get_chat_history(user_id=user_id, chat_id=chat_id) + + mock_chat_repo.get_chat_full.assert_called_once_with(chat_id) + + assert result.title == "test_title" + assert result.owner_id == user_id + assert result.created_at == create_time + assert result.messages == [] + + +@pytest.mark.asyncio +async def test_get_chat_history_chat_not_found(mock_chat_repo): + """Test that error is raised when there is no Chat with specified chat_id.""" + mock_chat_repo.get_chat_full.return_value = None + + service = ChatService(mock_chat_repo) + + with pytest.raises(HTTPException) as exc: + await service.get_chat_history(user_id=uuid4(), chat_id=uuid4()) + + assert exc.value.status_code == 404 + assert "not found" in exc.value.detail + + +@pytest.mark.asyncio +async def test_get_chat_history_user_is_not_chat_owner(mock_chat_repo): + """Test that error is raised when User doesn't own the exact Chat.""" + user_id = uuid4() + chat_id = uuid4() + mock_chat_repo.get_chat_full.return_value = DomainChat( + id=chat_id, + title="test_title", + owner_id=user_id, + created_at=datetime.now() + ) + + service = ChatService(mock_chat_repo) + + with pytest.raises(HTTPException) as exc: + await service.get_chat_history(user_id=uuid4(), chat_id=chat_id) + + assert exc.value.status_code == 400 + assert "doesn't have access to the chat" in exc.value.detail + + +@pytest.mark.asyncio +async def test_get_ask_question_success(mock_chat_repo): + """Test that new Messages are returned after QnA iteration.""" + user_id = uuid4() + chat_id = uuid4() + mock_chat_repo.get_chat_full.return_value = DomainChat( + id=chat_id, + title="test_title", + owner_id=user_id, + created_at=datetime.now() + ) + + question = "test_question" + answer = "test_llm_answer" + mock_chat_repo.add_message.side_effect = [ + DomainMessage( + id=uuid4(), + role="user", + content=question, + created_at=datetime.now() + ), + DomainMessage( + id=uuid4(), + role="assistant", + content=answer, + created_at=datetime.now() + ) + ] + service = ChatService(mock_chat_repo) + + result = await service.ask_question( + user_id=user_id, + chat_id=chat_id, + question=question + ) + + assert mock_chat_repo.add_message.call_count == 2 + assert result.content == answer + assert result.role == "assistant" + + +@pytest.mark.asyncio +async def test_get_ask_question_chat_not_found(mock_chat_repo): + """Test that error is raised when there is no Chat with specified chat_id.""" + user_id = uuid4() + chat_id = uuid4() + mock_chat_repo.get_chat_full.return_value = None + + service = ChatService(mock_chat_repo) + + with pytest.raises(HTTPException) as exc: + await service.ask_question( + user_id=user_id, + chat_id=chat_id, + question="test_question" + ) + + assert exc.value.status_code == 404 + assert "not found" in exc.value.detail + + +@pytest.mark.asyncio +async def test_get_ask_question_user_is_not_chat_owner(mock_chat_repo): + """Test that error is raised when User doesn't own the exact Chat.""" + user_id = uuid4() + chat_id = uuid4() + mock_chat_repo.get_chat_full.return_value = DomainChat( + id=chat_id, + title="test_title", + owner_id=user_id, + created_at=datetime.now() + ) + + service = ChatService(mock_chat_repo) + + with pytest.raises(HTTPException) as exc: + await service.ask_question( + user_id=uuid4(), + chat_id=chat_id, + question="test_question" + ) + + assert exc.value.status_code == 400 + assert "doesn't have access to the chat" in exc.value.detail From cc2bce1d8269e4cf1d5dfb8c7364ad1de0c45aad Mon Sep 17 00:00:00 2001 From: sardor014 Date: Sat, 27 Dec 2025 22:42:10 +0300 Subject: [PATCH 04/13] chore(ci): add ci with tests --- .env.example | 9 +++++++++ .github/workflows/ci.yml | 28 ++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..197f190 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DB=gitlab_db +DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/gitlab_db +TEST_DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/test_db +SECRET_KEY=insecure_secret_key_for_ci_and_dev +LLM_SERVICE_URL=http://llm-service:8001/api/v1/ask +MLOPS_SERVICE_URL=http://mlops-service:8002/api/v1/trigger_dag +ENCRYPTION_KEY="AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d1a008a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: Backend CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Create .env file + run: cp .env.example .env || touch .env + + - name: Start containers + run: docker compose up -d --build backend db + + - name: Run Tests + run: docker compose exec -T backend pytest -v + + - name: Stop containers + if: always() + run: docker compose down -v \ No newline at end of file From 5f83dab02271833efaff18ed84fc30853fb754a3 Mon Sep 17 00:00:00 2001 From: sardor014 Date: Sat, 27 Dec 2025 22:52:42 +0300 Subject: [PATCH 05/13] chore(ci): add db_test to ci --- .env.example | 2 +- .github/workflows/ci.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 197f190..96b8fe2 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres POSTGRES_DB=gitlab_db DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/gitlab_db -TEST_DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/test_db +TEST_DATABASE_URL=postgresql+asyncpg://postgres:postgres@db_test:5432/test_db SECRET_KEY=insecure_secret_key_for_ci_and_dev LLM_SERVICE_URL=http://llm-service:8001/api/v1/ask MLOPS_SERVICE_URL=http://mlops-service:8002/api/v1/trigger_dag diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1a008a..042b382 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: run: cp .env.example .env || touch .env - name: Start containers - run: docker compose up -d --build backend db + run: docker compose up -d --build --wait backend db db_test - name: Run Tests run: docker compose exec -T backend pytest -v From bd6e420de8cb180de0eadb3a0199b518c28d7b8f Mon Sep 17 00:00:00 2001 From: sardor014 Date: Sun, 28 Dec 2025 23:40:27 +0300 Subject: [PATCH 06/13] feat(backend): add tests for index router and index service --- alembic/versions/15a091196cf4_init_schema.py | 89 +++++ src/api/routers/indexing.py | 61 ++++ src/api/routers/repository.py | 52 +-- src/application/services/index_service.py | 10 +- src/domain/models/knowledge.py | 7 +- src/domain/repositories/gitlab_repo.py | 2 +- src/domain/repositories/job_repo.py | 19 +- src/infrastructure/db/models/job.py | 2 - .../db/repositories/sqlalchemy_gitlab_repo.py | 23 +- .../db/repositories/sqlalchemy_job_repo.py | 14 +- src/infrastructure/external/mlops_client.py | 4 +- src/main.py | 3 +- tests/api/test_indexing_router.py | 183 ++++++++++ tests/integration/test_job_repo.py | 170 ++++++++++ tests/unit/test_index_service.py | 312 ++++++++++++++++++ 15 files changed, 864 insertions(+), 87 deletions(-) create mode 100644 alembic/versions/15a091196cf4_init_schema.py create mode 100644 src/api/routers/indexing.py create mode 100644 tests/api/test_indexing_router.py create mode 100644 tests/integration/test_job_repo.py create mode 100644 tests/unit/test_index_service.py diff --git a/alembic/versions/15a091196cf4_init_schema.py b/alembic/versions/15a091196cf4_init_schema.py new file mode 100644 index 0000000..16b2f3b --- /dev/null +++ b/alembic/versions/15a091196cf4_init_schema.py @@ -0,0 +1,89 @@ +"""init_schema + +Revision ID: 15a091196cf4 +Revises: +Create Date: 2025-12-28 18:41:13.254645 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '15a091196cf4' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('gitlab_configs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('url', sa.String(), nullable=False), + sa.Column('private_token_encrypted', sa.Text(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('indexing_jobs', + sa.Column('status', sa.String(length=50), nullable=False), + sa.Column('repository_ids', sa.JSON(), nullable=False), + sa.Column('details', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('id', sa.UUID(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_indexing_jobs_status'), 'indexing_jobs', ['status'], unique=False) + op.create_table('roles', + sa.Column('name', sa.String(length=50), nullable=False), + sa.Column('permissions', sa.JSON(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_roles_name'), 'roles', ['name'], unique=True) + op.create_table('users', + sa.Column('username', sa.String(length=100), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('role', sa.String(length=50), nullable=False), + sa.Column('hashed_password', sa.String(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True) + op.create_table('chats', + sa.Column('title', sa.String(length=200), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('owner_id', sa.UUID(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('messages', + sa.Column('chat_id', sa.UUID(), nullable=False), + sa.Column('role', sa.String(length=20), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('sources', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.ForeignKeyConstraint(['chat_id'], ['chats.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('messages') + op.drop_table('chats') + op.drop_index(op.f('ix_users_username'), table_name='users') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + op.drop_index(op.f('ix_roles_name'), table_name='roles') + op.drop_table('roles') + op.drop_index(op.f('ix_indexing_jobs_status'), table_name='indexing_jobs') + op.drop_table('indexing_jobs') + op.drop_table('gitlab_configs') + # ### end Alembic commands ### diff --git a/src/api/routers/indexing.py b/src/api/routers/indexing.py new file mode 100644 index 0000000..9812ba8 --- /dev/null +++ b/src/api/routers/indexing.py @@ -0,0 +1,61 @@ + +from fastapi import APIRouter, Depends, HTTPException, status + +from src.api.dependencies import get_current_admin_user, get_index_service +from src.api.schemas.repository import ( + IndexingJob, + JobStatusUpdate, + SyncRequest, +) +from src.application.services.index_service import IndexService + +router_indexing = APIRouter(dependencies=[Depends(get_current_admin_user)]) + + +@router_indexing.post("/trigger", response_model=IndexingJob) +async def trigger_indexing( + sync_request: SyncRequest, + service: IndexService = Depends(get_index_service) +): + """Start an indexing of repositories by their ids.""" + return await service.trigger_indexing( + repository_ids=sync_request.repository_ids + ) + + +@router_indexing.delete("/{job_id}") +async def delete_indexing_job( + job_id: str, + service: IndexService = Depends(get_index_service) +): + """Delete an existing job by its id. + + Return true if deleted, false if the job doesn't exist. + """ + return await service.delete_indexind_job(job_id) + + +@router_indexing.get("/status/{job_id}", response_model=IndexingJob) +async def get_indexing_status( + job_id: str, + service: IndexService = Depends(get_index_service) +): + """Get status of indexing job by its id.""" + return await service.get_indexing_status(job_id) + + +@router_indexing.put("/status/{job_id}", response_model=IndexingJob) +async def update_indexing_status( + job_id: str, + status_update: JobStatusUpdate, + service: IndexService = Depends(get_index_service) +): + """Update a status of an existing job by its id.""" + updated_job = await service.update_indexing_status(job_id, status_update) + if not updated_job: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Job with id {job_id} not found" + ) + + return updated_job diff --git a/src/api/routers/repository.py b/src/api/routers/repository.py index c35bb55..eb5a497 100644 --- a/src/api/routers/repository.py +++ b/src/api/routers/repository.py @@ -1,14 +1,11 @@ from typing import List -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, status from src.api.dependencies import get_current_admin_user, get_index_service from src.api.schemas.repository import ( GitLabConfigCreate, - IndexingJob, - JobStatusUpdate, Repository, - SyncRequest, ) from src.application.services.index_service import IndexService @@ -33,50 +30,3 @@ async def list_gitlab_repositories( ): """Get list of repositories that are available for indexing.""" return await service.list_repositories() - - -@router_repository.post("/trigger", response_model=IndexingJob) -async def trigger_indexing( - sync_request: SyncRequest, - service: IndexService = Depends(get_index_service) -): - """Start an indexing of repositories by their ids.""" - return await service.trigger_indexing( - repository_ids=sync_request.repository_ids - ) - -@router_repository.delete("/{job_id}") -async def delete_indexing_job( - job_id: str, - service: IndexService = Depends(get_index_service) -): - """Delete an existing job by its id. - - Return true if deleted, false if the job doesn't exist. - """ - return await service.delete_indexind_job(job_id) - -@router_repository.get("/status/{job_id}", response_model=IndexingJob) -async def get_indexing_status( - job_id: str, - service: IndexService = Depends(get_index_service) -): - """Get status of indexing job by its id.""" - return await service.get_indexing_status(job_id) - - -@router_repository.put("/status/{job_id}", response_model=IndexingJob) -async def update_indexing_status( - job_id: str, - status_update: JobStatusUpdate, - service: IndexService = Depends(get_index_service) -): - """Update a status of an existing job by its id.""" - updated_job = await service.update_indexing_status(job_id, status_update) - if not updated_job: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Job with id {job_id} not found" - ) - - return updated_job diff --git a/src/application/services/index_service.py b/src/application/services/index_service.py index b65ea1b..51b4089 100644 --- a/src/application/services/index_service.py +++ b/src/application/services/index_service.py @@ -3,9 +3,9 @@ from fastapi import HTTPException, status from pydantic import UUID4 -from src.api.schemas.repository import IndexingJob as IndexingJobSchema, JobStatusUpdate +from src.api.schemas.repository import JobStatusUpdate from src.core.settings import settings -from src.domain.models.knowledge import Repository +from src.domain.models.knowledge import IndexingJob, Repository from src.domain.repositories.gitlab_repo import IGitLabRepository from src.domain.repositories.job_repo import IJobRepository from src.infrastructure.external.gitlab_client import GitLabClient @@ -52,7 +52,7 @@ async def list_repositories(self) -> List[Repository]: token=raw_token ) - async def trigger_indexing(self, repository_ids: List[UUID4]) -> IndexingJobSchema: + async def trigger_indexing(self, repository_ids: List[UUID4]) -> IndexingJob: """Trigger and run indexing service.""" config = await self.gitlab_repo.get_config() if not config: @@ -89,7 +89,7 @@ async def delete_indexind_job(self, job_id: str) -> bool: return {"status": "error", "message": f"Job {job_id} doesn't exist."} - async def get_indexing_status(self, job_id: str) -> Optional[IndexingJobSchema]: + async def get_indexing_status(self, job_id: str) -> Optional[IndexingJob]: """Get status for existing indexing job.""" return await self.job_repo.get_job(job_id) @@ -97,6 +97,6 @@ async def update_indexing_status( self, job_id: str, status_update: JobStatusUpdate - ) -> Optional[IndexingJobSchema]: + ) -> Optional[IndexingJob]: """Update a status of an existing job by its id.""" return await self.job_repo.update_job_status(job_id, status_update.status) diff --git a/src/domain/models/knowledge.py b/src/domain/models/knowledge.py index e0f3b3e..e1aef34 100644 --- a/src/domain/models/knowledge.py +++ b/src/domain/models/knowledge.py @@ -15,14 +15,15 @@ class JobStatus(str, Enum): FAILED = "FAILED" -class GitLabConfigModel(BaseModel): +class GitLabConfig(BaseModel): """Data structure for GitLab config model.""" model_config = ConfigDict(from_attributes=True) - id: UUID4 - url: str + id: int + url: HttpUrl + private_token_encrypted: str class Repository(BaseModel): diff --git a/src/domain/repositories/gitlab_repo.py b/src/domain/repositories/gitlab_repo.py index 3da4448..9876002 100644 --- a/src/domain/repositories/gitlab_repo.py +++ b/src/domain/repositories/gitlab_repo.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod from typing import Optional -from src.infrastructure.db.models.gitlab import GitLabConfig +from src.domain.models.knowledge import GitLabConfig class IGitLabRepository(ABC): diff --git a/src/domain/repositories/job_repo.py b/src/domain/repositories/job_repo.py index 72ffbd4..bd6421f 100644 --- a/src/domain/repositories/job_repo.py +++ b/src/domain/repositories/job_repo.py @@ -1,8 +1,9 @@ from abc import ABC, abstractmethod from typing import List, Optional -from src.domain.models.knowledge import JobStatus -from src.infrastructure.db.models.job import IndexingJob +from pydantic import UUID4 + +from src.domain.models.knowledge import IndexingJob, JobStatus class IJobRepository(ABC): @@ -12,8 +13,8 @@ class IJobRepository(ABC): @abstractmethod async def create_job( self, - job_id: str, - repo_ids: List[int], + job_id: UUID4, + repo_ids: List[UUID4], status: JobStatus, details: str ) -> IndexingJob: @@ -21,7 +22,7 @@ async def create_job( raise NotImplementedError @abstractmethod - async def delete_job(self, job_id: str) -> bool: + async def delete_job(self, job_id: UUID4) -> bool: """Delete an existing job by its id. Return true if deleted, false if the job doesn't exist. @@ -29,11 +30,15 @@ async def delete_job(self, job_id: str) -> bool: raise NotImplementedError @abstractmethod - async def get_job(self, job_id: str) -> Optional[IndexingJob]: + async def get_job(self, job_id: UUID4) -> Optional[IndexingJob]: """Get an indexing job by its id.""" raise NotImplementedError @abstractmethod - async def update_job_status(self, job_id: str, new_status: JobStatus) -> Optional[IndexingJob]: + async def update_job_status( + self, + job_id: UUID4, + new_status: JobStatus + ) -> Optional[IndexingJob]: """Update a status of an existing job by its id.""" raise NotImplementedError diff --git a/src/infrastructure/db/models/job.py b/src/infrastructure/db/models/job.py index b180eb0..4c0b261 100644 --- a/src/infrastructure/db/models/job.py +++ b/src/infrastructure/db/models/job.py @@ -13,8 +13,6 @@ class IndexingJob(Base): __tablename__ = "indexing_jobs" - id: Mapped[str] = mapped_column(String, primary_key=True) - status: Mapped[str] = mapped_column(String(50), nullable=False, index=True) repository_ids: Mapped[list] = mapped_column(JSON, nullable=False) diff --git a/src/infrastructure/db/repositories/sqlalchemy_gitlab_repo.py b/src/infrastructure/db/repositories/sqlalchemy_gitlab_repo.py index 751fd4e..6ad9512 100644 --- a/src/infrastructure/db/repositories/sqlalchemy_gitlab_repo.py +++ b/src/infrastructure/db/repositories/sqlalchemy_gitlab_repo.py @@ -3,8 +3,9 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from src.domain.models.knowledge import GitLabConfig as DomainGitLabConfig from src.domain.repositories.gitlab_repo import IGitLabRepository -from src.infrastructure.db.models.gitlab import GitLabConfig +from src.infrastructure.db.models.gitlab import GitLabConfig as ORMGitLabConfig class SqlAlchemyGitLabRepository(IGitLabRepository): @@ -14,32 +15,38 @@ class SqlAlchemyGitLabRepository(IGitLabRepository): def __init__(self, session: AsyncSession): self.session = session - async def save_config(self, url: str, encrypted_token: str) -> GitLabConfig: + async def save_config(self, url: str, encrypted_token: str) -> DomainGitLabConfig: """Save or update the configuration. Use the singletone approach with id=1. If config with id=1 already exists then update it, otherwise create a new one. """ - stmt = select(GitLabConfig).where(GitLabConfig.id == 1) + stmt = select(ORMGitLabConfig).where(ORMGitLabConfig.id == 1) result = await self.session.execute(stmt) orm_gitlab_config = result.scalar_one_or_none() if not orm_gitlab_config: - config = GitLabConfig( + orm_gitlab_config = ORMGitLabConfig( id=1, url=url, private_token_encrypted=encrypted_token ) - self.session.add(config) + self.session.add(orm_gitlab_config) else: orm_gitlab_config.url = url orm_gitlab_config.private_token_encrypted = encrypted_token + config = DomainGitLabConfig.model_validate(orm_gitlab_config) + await self.session.flush() return config - async def get_config(self) -> Optional[GitLabConfig]: + async def get_config(self) -> Optional[DomainGitLabConfig]: """Get current configuration.""" - stmt = select(GitLabConfig).where(GitLabConfig.id == 1) + stmt = select(ORMGitLabConfig).where(ORMGitLabConfig.id == 1) result = await self.session.execute(stmt) - return result.scalar_one_or_none() + config = result.scalar_one_or_none() + + if config: + return DomainGitLabConfig.model_validate(config) + return None diff --git a/src/infrastructure/db/repositories/sqlalchemy_job_repo.py b/src/infrastructure/db/repositories/sqlalchemy_job_repo.py index ac6d35d..bcfa13d 100644 --- a/src/infrastructure/db/repositories/sqlalchemy_job_repo.py +++ b/src/infrastructure/db/repositories/sqlalchemy_job_repo.py @@ -1,6 +1,7 @@ from datetime import datetime from typing import List, Optional +from pydantic import UUID4 from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -18,17 +19,16 @@ def __init__(self, session: AsyncSession): async def create_job( self, - job_id: str, - repo_ids: List[int], + job_id: UUID4, + repo_ids: List[UUID4], status: JobStatus, details: str ) -> DomainIndexingJob: """Create an indexing job.""" clean_repo_ids = [str(item) for item in repo_ids] - clean_job_id = str(job_id) job = ORMIndexingJob( - id=clean_job_id, + id=job_id, repository_ids=clean_repo_ids, status=status, details=details @@ -38,7 +38,7 @@ async def create_job( await self.session.flush() return DomainIndexingJob.model_validate(job) - async def delete_job(self, job_id: str) -> bool: + async def delete_job(self, job_id: UUID4) -> bool: """Delete an existing job by its id. Return true if deleted, false if the job doesn't exist. @@ -55,7 +55,7 @@ async def delete_job(self, job_id: str) -> bool: return True - async def get_job(self, job_id: str) -> Optional[DomainIndexingJob]: + async def get_job(self, job_id: UUID4) -> Optional[DomainIndexingJob]: """Get an indexing job by its id.""" stmt = select(ORMIndexingJob).where(ORMIndexingJob.id == job_id) result = await self.session.execute(stmt) @@ -67,7 +67,7 @@ async def get_job(self, job_id: str) -> Optional[DomainIndexingJob]: async def update_job_status( self, - job_id: str, + job_id: UUID4, new_status: JobStatus ) -> Optional[DomainIndexingJob]: """Update a status of an existing job by its id.""" diff --git a/src/infrastructure/external/mlops_client.py b/src/infrastructure/external/mlops_client.py index d2b4eab..57719b2 100644 --- a/src/infrastructure/external/mlops_client.py +++ b/src/infrastructure/external/mlops_client.py @@ -3,7 +3,7 @@ from pydantic import UUID4 -from src.api.schemas.repository import IndexingJob +from src.domain.models.knowledge import IndexingJob class MLOpsClient: @@ -21,7 +21,7 @@ async def trigger_indexing( ) -> IndexingJob: """Make a request to start an indexing process.""" return IndexingJob( - id=str(uuid.uuid4()), + id=uuid.uuid4(), status="RUNNING", repository_ids=repo_ids, created_at=datetime.now(), diff --git a/src/main.py b/src/main.py index 0482651..e523965 100644 --- a/src/main.py +++ b/src/main.py @@ -1,6 +1,6 @@ from fastapi import FastAPI -from api.routers import admin, auth, chat, repository +from api.routers import admin, auth, chat, indexing, repository app = FastAPI( title="Скелет", @@ -11,3 +11,4 @@ app.include_router(chat.router_chat, prefix="/v1/chat") app.include_router(admin.router_admin, prefix="/v1/admin") app.include_router(repository.router_repository, prefix="/v1/repository") +app.include_router(indexing.router_indexing, prefix="/v1/indexing") diff --git a/tests/api/test_indexing_router.py b/tests/api/test_indexing_router.py new file mode 100644 index 0000000..d1ca41b --- /dev/null +++ b/tests/api/test_indexing_router.py @@ -0,0 +1,183 @@ +from datetime import datetime +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +import pytest_asyncio +from fastapi.testclient import TestClient +from httpx import ASGITransport, AsyncClient + +from src.api.dependencies import get_admin_service, get_current_admin_user, get_index_service +from src.application.services.admin_service import AdminService +from src.application.services.index_service import IndexService +from src.domain.models.knowledge import JobStatus +from src.domain.models.user import User as DomainUser +from src.main import app + +client = TestClient(app) + + +BASE_URL = "/v1/indexing" + + +@pytest_asyncio.fixture(scope="function") +async def ac(): + """Create AsyncClient for tests.""" + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +@pytest_asyncio.fixture +def mock_admin_service(): + """Create mock for AdminSevice.""" + service = AsyncMock(spec=AdminService) + return service + + +@pytest_asyncio.fixture +def mock_index_service(): + """Create mock for IndexSevice.""" + service = AsyncMock(spec=IndexService) + return service + + +@pytest_asyncio.fixture(autouse=True) +def override_dependencies(mock_admin_service, mock_index_service): + """Override dependencies for AdminService's mock.""" + app.dependency_overrides[get_current_admin_user] = lambda: DomainUser( + id=uuid4(), + username="admin", + email="admin@test.com", + role="admin", + hashed_password="hash" + ) + + app.dependency_overrides[get_admin_service] = lambda: mock_admin_service + app.dependency_overrides[get_index_service] = lambda: mock_index_service + + yield + + app.dependency_overrides = {} + + +@pytest.mark.asyncio +async def test_trigger_indexing_success(ac, mock_index_service): + """Test that endpoint returns created indexing_job.""" + repo_ids = [str(uuid4())] + payload = {"repository_ids": repo_ids} + + job_id = str(uuid4()) + mock_job_response = { + "id": job_id, + "status": JobStatus.RUNNING.value, + "repository_ids": repo_ids, + "created_at": datetime.now(), + "details": "run" + } + mock_index_service.trigger_indexing.return_value = mock_job_response + + response = await ac.post(f"{BASE_URL}/trigger", json=payload) + + assert response.status_code == 200 + + data = response.json() + assert data["id"] == job_id + assert data["status"] == JobStatus.RUNNING.value + assert str(data["repository_ids"][0]) == repo_ids[0] + + +@pytest.mark.asyncio +async def test_delete_indexing_job_success(ac, mock_index_service): + """Test that endpoint deletes existing indexing_job.""" + job_id = str(uuid4()) + mock_index_service.delete_indexind_job.return_value = True + + response = await ac.delete(f"{BASE_URL}/{job_id}") + + assert response.status_code == 200 + assert response.json() is True + + mock_index_service.delete_indexind_job.assert_called_once_with(job_id) + + +@pytest.mark.asyncio +async def test_delete_indexing_job_not_found(ac, mock_index_service): + """Test that endpoint cannot delete indexing_job that doesn't exist.""" + job_id = str(uuid4()) + mock_index_service.delete_indexind_job.return_value = False + + response = await ac.delete(f"{BASE_URL}/{job_id}") + + assert response.status_code == 200 + assert response.json() is False + + +@pytest.mark.asyncio +async def test_get_indexing_status_success(ac, mock_index_service): + """Test that endpoint returns the status of indexing_job.""" + job_id = str(uuid4()) + mock_job_response = { + "id": job_id, + "status": JobStatus.RUNNING.value, + "repository_ids": [], + "created_at": datetime.now(), + "details": "run" + } + mock_index_service.get_indexing_status.return_value = mock_job_response + + response = await ac.get(f"{BASE_URL}/status/{job_id}") + + assert response.status_code == 200 + + data = response.json() + assert data["id"] == job_id + assert data["status"] == JobStatus.RUNNING.value + + mock_index_service.get_indexing_status.assert_called_once_with(job_id) + + +@pytest.mark.asyncio +async def test_update_indexing_status_success(ac, mock_index_service): + """Test that endpoint updated status of an existing indexing_job.""" + job_id = str(uuid4()) + + payload = { + "status": JobStatus.SUCCESS.value, + "details": "done" + } + + mock_job_response = { + "id": job_id, + "status": JobStatus.SUCCESS.value, + "repository_ids": [], + "created_at": datetime.now(), + "details": "done" + } + mock_index_service.update_indexing_status.return_value = mock_job_response + + response = await ac.put(f"{BASE_URL}/status/{job_id}", json=payload) + + assert response.status_code == 200 + + data = response.json() + assert data["status"] == JobStatus.SUCCESS.value + assert data["details"] == "done" + + assert mock_index_service.update_indexing_status.call_count == 1 + args = mock_index_service.update_indexing_status.call_args + assert args[0][0] == job_id + assert args[0][1].status == JobStatus.SUCCESS.value + + +@pytest.mark.asyncio +async def test_update_indexing_status_not_found(ac, mock_index_service): + """Test that endpoint returns error when indexing_job doesn't exist.""" + job_id = str(uuid4()) + payload = {"status": JobStatus.FAILED.value} + + mock_index_service.update_indexing_status.return_value = None + + response = await ac.put(f"{BASE_URL}/status/{job_id}", json=payload) + + assert response.status_code == 404 + assert response.json()["detail"] == f"Job with id {job_id} not found" diff --git a/tests/integration/test_job_repo.py b/tests/integration/test_job_repo.py new file mode 100644 index 0000000..34ba655 --- /dev/null +++ b/tests/integration/test_job_repo.py @@ -0,0 +1,170 @@ +from typing import List, Optional +from uuid import uuid4 + +import pytest +import pytest_asyncio +from pydantic import UUID4 + +from src.domain.models.knowledge import IndexingJob as DomainIndexingJob, JobStatus +from src.infrastructure.db.repositories.sqlalchemy_job_repo import SqlAlchemyJobRepository + + +@pytest.fixture(scope="function") +def job_repo(session): + """Return SqlAlchemyJobRepository's fixture for tests.""" + return SqlAlchemyJobRepository(session) + + +@pytest_asyncio.fixture(scope="function") +async def job_factory(job_repo): + """Create Job for tests. + + If specific values are not provided, factory creates a job with unique values. + """ + + async def _create_indexing_job( + job_id: str = uuid4(), + status: JobStatus = JobStatus.RUNNING, + repository_ids: Optional[List[UUID4]] = None, + details: Optional[str] = None + ) -> DomainIndexingJob: + if repository_ids is None: + repository_ids = [] + + return await job_repo.create_job( + job_id=job_id, + repo_ids=repository_ids, + status=status, + details=details + ) + + return _create_indexing_job + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "job_status, details", + [ + (JobStatus.FAILED, "text"), + (JobStatus.PENDING, None), + (JobStatus.RUNNING, "text"), + (JobStatus.SUCCESS, None) + ] +) +async def test_create_job_success(job_factory, job_status, details): + """Test that Job is created.""" + repository_ids = [uuid4()] + created_job = await job_factory( + status=job_status, + repository_ids=repository_ids, + details=details + ) + + assert created_job.id is not None + assert created_job.repository_ids == repository_ids + assert created_job.status == job_status + assert created_job.details == details + assert created_job.status == job_status + assert created_job.created_at is not None + + +@pytest.mark.asyncio +async def test_delete_job_success(job_repo, job_factory): + """Test that Job is deleted.""" + created_job = await job_factory( + repository_ids=[uuid4()] + ) + + delete_status = await job_repo.delete_job(created_job.id) + + assert delete_status + + job_from_db = await job_repo.get_job(created_job.id) + + assert job_from_db is None + + +@pytest.mark.asyncio +async def test_delete_job_not_found(job_repo): + """Test that Job is not deleted when it doesn't exist.""" + delete_status = await job_repo.delete_job(uuid4()) + + assert not delete_status + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "job_status, details", + [ + (JobStatus.FAILED, "text"), + (JobStatus.PENDING, None), + (JobStatus.RUNNING, "text"), + (JobStatus.SUCCESS, None) + ] +) +async def test_get_job_success(job_repo, job_factory, job_status, details): + """Test that Job is extracted by its id.""" + created_job = await job_factory( + repository_ids=[uuid4()], + status=job_status, + details=details + ) + + job_from_db = await job_repo.get_job(created_job.id) + + assert job_from_db == created_job + + +@pytest.mark.asyncio +async def test_get_job_not_found(job_repo): + """Test that Job is not extracted when it doesn't exist.""" + job_from_db = await job_repo.get_job(uuid4()) + + assert job_from_db is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "old_job_status, new_job_status", + [ + (JobStatus.FAILED, JobStatus.PENDING), + (JobStatus.PENDING, JobStatus.RUNNING), + (JobStatus.RUNNING, JobStatus.SUCCESS), + (JobStatus.SUCCESS, JobStatus.FAILED) + ] +) +async def test_update_job_status_success(job_repo, job_factory, old_job_status, new_job_status): + """Test that Job's status is updated.""" + created_job = await job_factory( + repository_ids=[uuid4()], + status=old_job_status, + ) + + updated_job = await job_repo.update_job_status( + job_id=created_job.id, + new_status=new_job_status + ) + + assert updated_job.status == new_job_status + assert updated_job.id == created_job.id + assert updated_job.created_at == created_job.created_at + if new_job_status in [JobStatus.FAILED, JobStatus.SUCCESS]: + assert updated_job.finished_at is not None + else: + assert updated_job.finished_at is None + + + updated_job_from_db = await job_repo.get_job(created_job.id) + + assert updated_job_from_db == updated_job + + +@pytest.mark.asyncio +async def test_update_job_status_not_found(job_repo): + """Test that Job's status is not updated when it doesn't exist.""" + updated_job = await job_repo.update_job_status( + job_id=uuid4(), + new_status=JobStatus.FAILED + ) + + assert updated_job is None diff --git a/tests/unit/test_index_service.py b/tests/unit/test_index_service.py new file mode 100644 index 0000000..38efd14 --- /dev/null +++ b/tests/unit/test_index_service.py @@ -0,0 +1,312 @@ +from datetime import datetime +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from fastapi import HTTPException + +from src.api.schemas.repository import JobStatusUpdate +from src.application.services.index_service import IndexService +from src.domain.models.knowledge import ( + GitLabConfig as DomainGitLabConfig, + IndexingJob, + JobStatus, + Repository, +) + + +@pytest.fixture(scope="function") +def mock_gitlab_repo(): + """Create AsyncMock for gitlab_repo.""" + return AsyncMock() + + +@pytest.fixture(scope="function") +def mock_job_repo(): + """Create AsyncMock for job_repo.""" + return AsyncMock() + + +@pytest.fixture +def gitlab_config(): + """Create mock fot DomainGitLabConfig.""" + return DomainGitLabConfig( + id=1, + url="https://test.com", + private_token_encrypted="encrypted_test_token" + ) + + +@pytest.fixture +def repository(): + """Create mock fot Repository.""" + return Repository( + id=uuid4(), + name="test_repository", + path_with_namespace="test1/test2", + web_url="https://test.com" + ) + + +@pytest.fixture +def indexing_job(repository): + """Create mock fot IndexingJob.""" + return IndexingJob( + id=uuid4(), + status=JobStatus.RUNNING, + repository_ids=[repository.id], + created_at=datetime.now() + ) + +@pytest.mark.asyncio +async def test_configure_gitlab_success( + mock_gitlab_repo, + mock_job_repo, + gitlab_config, + mocker +): + """Test that ok message is returned when config is configured.""" + mocker.patch( + "src.application.services.index_service.encrypt_data", + side_effect=lambda x: f"encrypted_{x}" + ) + mock_gitlab_repo.save_config.return_value = gitlab_config + service = IndexService(mock_gitlab_repo, mock_job_repo) + + result = await service.configure_gitlab("test_url", "test_token") + + mock_gitlab_repo.save_config.assert_called_once_with("test_url", "encrypted_test_token") + assert result == {"status": "ok", "message": "GitLab configuration saved successfully."} + + +@pytest.mark.asyncio +async def test_list_repositories_success( + mock_gitlab_repo, + mock_job_repo, + gitlab_config, + repository, + mocker +): + """Test that service returns list of existing repositories.""" + mock_gitlab_repo.get_config.return_value = gitlab_config + mock_gitlab_repo.gitlab_client.list_projects.return_value = [repository] + mocker.patch( + "src.application.services.index_service.decrypt_data", + side_effect=lambda x: f"decrypted_{x}" + ) + service = IndexService(mock_gitlab_repo, mock_job_repo) + + mock_client = AsyncMock() + mock_client.list_projects.return_value = [repository] + service.gitlab_client = mock_client + + result = await service.list_repositories() + + mock_gitlab_repo.get_config.assert_called_once() + service.gitlab_client.list_projects.assert_called_once() + assert result == [repository] + + +@pytest.mark.asyncio +async def test_list_repositories_success_empty_repository_list( + mock_gitlab_repo, + mock_job_repo, + gitlab_config, + repository, + mocker +): + """Test that service returns an empty list when there are no existing repositories.""" + mock_gitlab_repo.get_config.return_value = gitlab_config + mock_gitlab_repo.gitlab_client.list_projects.return_value = [repository] + mocker.patch( + "src.application.services.index_service.decrypt_data", + side_effect=lambda x: f"decrypted_{x}" + ) + service = IndexService(mock_gitlab_repo, mock_job_repo) + + mock_client = AsyncMock() + mock_client.list_projects.return_value = [] + service.gitlab_client = mock_client + + result = await service.list_repositories() + + mock_gitlab_repo.get_config.assert_called_once() + service.gitlab_client.list_projects.assert_called_once() + assert result == [] + + +@pytest.mark.asyncio +async def test_list_repositories_config_not_found( + mock_gitlab_repo, + mock_job_repo +): + """Test that service returns error when config is not configured.""" + mock_gitlab_repo.get_config.return_value = None + service = IndexService(mock_gitlab_repo, mock_job_repo) + + with pytest.raises(HTTPException) as exc: + await service.list_repositories() + + mock_gitlab_repo.get_config.assert_called_once() + assert exc.value.status_code == 404 + assert exc.value.detail == "GitLab is not configured yet. Please add config first." + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "job_status", + [ + JobStatus.FAILED, + JobStatus.PENDING, + JobStatus.RUNNING, + JobStatus.SUCCESS + ] +) +async def test_trigger_indexing_success( + mock_gitlab_repo, + mock_job_repo, + gitlab_config, + repository, + indexing_job, + mocker, + job_status +): + """Test that service creates job with specified status.""" + mock_gitlab_repo.get_config.return_value = gitlab_config + mocker.patch( + "src.application.services.index_service.decrypt_data", + side_effect=lambda x: f"decrypted_{x}" + ) + + indexing_job.status = job_status + mock_job_repo.create_job.return_value = indexing_job + service = IndexService(mock_gitlab_repo, mock_job_repo) + + mock_client = AsyncMock() + mock_client.trigger_indexing.return_value = indexing_job + service.mlops_client = mock_client + + result = await service.trigger_indexing([repository.id]) + + mock_gitlab_repo.get_config.assert_called_once() + mock_client.trigger_indexing.assert_called_once_with( + repo_ids=[repository.id], + gitlab_url=gitlab_config.url, + gitlab_token="decrypted_encrypted_test_token" + ) + mock_job_repo.create_job.assert_called_once_with( + job_id=indexing_job.id, + repo_ids=[repository.id], + status=indexing_job.status, + details=None + ) + assert result == indexing_job + + +@pytest.mark.asyncio +async def test_delete_indexing_job_success( + mock_gitlab_repo, + mock_job_repo, + indexing_job +): + """Test that service delete an existing indexing_job.""" + ok_message = { + "status": "ok", + "message": f"Job {indexing_job.id} has been deleted successfully." + } + mock_job_repo.delete_job.return_value = True + + service = IndexService(mock_gitlab_repo, mock_job_repo) + + result = await service.delete_indexind_job(indexing_job.id) + + mock_job_repo.delete_job.assert_called_once_with(indexing_job.id) + assert result == ok_message + + +@pytest.mark.asyncio +async def test_delete_indexing_job_doesnt_exist( + mock_gitlab_repo, + mock_job_repo, + indexing_job +): + """Test that service returns error message when indexing_job doesn't exist.""" + error_message = { + "status": "error", + "message": f"Job {indexing_job.id} doesn't exist." + } + mock_job_repo.delete_job.return_value = False + + service = IndexService(mock_gitlab_repo, mock_job_repo) + + result = await service.delete_indexind_job(indexing_job.id) + + mock_job_repo.delete_job.assert_called_once_with(indexing_job.id) + assert result == error_message + + +@pytest.mark.asyncio +async def test_get_indexing_status_success( + mock_gitlab_repo, + mock_job_repo, + indexing_job +): + """Test that service returns indexing_job with specified id.""" + mock_job_repo.get_job.return_value = indexing_job + + service = IndexService(mock_gitlab_repo, mock_job_repo) + + result = await service.get_indexing_status(indexing_job.id) + + mock_job_repo.get_job.assert_called_once_with(indexing_job.id) + assert result == indexing_job + + +@pytest.mark.asyncio +async def test_get_indexing_status_job_not_found( + mock_gitlab_repo, + mock_job_repo, + indexing_job +): + """Test that service returnsNone when indexing_job doesn't exist.""" + mock_job_repo.get_job.return_value = None + + service = IndexService(mock_gitlab_repo, mock_job_repo) + result = await service.get_indexing_status(indexing_job.id) + + mock_job_repo.get_job.assert_called_once_with(indexing_job.id) + assert result is None + + +@pytest.mark.ayncio +@pytest.mark.parametrize( + "job_status", + [ + JobStatus.FAILED, + JobStatus.PENDING, + JobStatus.RUNNING, + JobStatus.SUCCESS + ] +) +async def test_update_indexing_status_success( + mock_gitlab_repo, + mock_job_repo, + indexing_job, + job_status +): + """Test that service updates indexing_job's status.""" + update_data = JobStatusUpdate( + status=job_status + ) + + indexing_job.status = job_status + mock_job_repo.update_job_status.return_value = indexing_job + + service = IndexService(mock_gitlab_repo, mock_job_repo) + + result = await service.update_indexing_status( + job_id=indexing_job.id, + status_update=update_data + ) + + assert result == indexing_job From 59df43d64751ada225b3d07f638ac2433dc3f188 Mon Sep 17 00:00:00 2001 From: sardor014 Date: Mon, 29 Dec 2025 22:56:28 +0300 Subject: [PATCH 07/13] feat(backend): add tests for repository router and gitlab repo --- pyproject.toml | 1 + tests/api/test_repository_router.py | 119 ++++++++++++++++++++++++++ tests/integration/test_gitlab_repo.py | 66 ++++++++++++++ 3 files changed, 186 insertions(+) create mode 100644 tests/api/test_repository_router.py create mode 100644 tests/integration/test_gitlab_repo.py diff --git a/pyproject.toml b/pyproject.toml index 942a4e9..6d55c35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,5 +64,6 @@ asyncio_mode = "auto" [dependency-groups] dev = [ + "pytest-cov>=7.0.0", "ruff>=0.14.1", ] diff --git a/tests/api/test_repository_router.py b/tests/api/test_repository_router.py new file mode 100644 index 0000000..cc41083 --- /dev/null +++ b/tests/api/test_repository_router.py @@ -0,0 +1,119 @@ +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +import pytest_asyncio +from fastapi.testclient import TestClient +from httpx import ASGITransport, AsyncClient + +from src.api.dependencies import get_current_admin_user, get_index_service +from src.application.services.index_service import IndexService +from src.domain.models.user import User as DomainUser +from src.main import app + +client = TestClient(app) + + +BASE_URL = "/v1/repository" + + +@pytest_asyncio.fixture(scope="function") +async def ac(): + """Create AsyncClient for tests.""" + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +@pytest_asyncio.fixture +def mock_index_service(): + """Create mock for IndexSevice.""" + service = AsyncMock(spec=IndexService) + return service + + +@pytest_asyncio.fixture(autouse=True) +def override_dependencies(mock_index_service): + """Override dependencies for IndexService's mock.""" + app.dependency_overrides[get_current_admin_user] = lambda: DomainUser( + id=uuid4(), + username="admin", + email="admin@test.com", + role="admin", + hashed_password="hash" + ) + + app.dependency_overrides[get_index_service] = lambda: mock_index_service + + yield + + app.dependency_overrides = {} + + +@pytest.mark.asyncio +async def test_configure_gitlab_success(ac, mock_index_service, mocker): + """Test that endpoint returns ok message when config is configured.""" + mocker.patch( + "src.application.services.index_service.encrypt_data", + side_effect=lambda x: f"encrypted_{x}" + ) + payload = { + "url": "https://gitlab.example.com", + "private_token": "secret-token-123" + } + + mock_response = {"status": "ok", "message": "GitLab configuration saved successfully."} + mock_index_service.configure_gitlab.return_value = mock_response + + response = await ac.post(f"{BASE_URL}/config", json=payload) + + assert response.status_code == 202 + assert response.json() == mock_response + + mock_index_service.configure_gitlab.assert_called_once_with( + url="https://gitlab.example.com", + private_token="secret-token-123" + ) + + +@pytest.mark.asyncio +async def test_list_gitlab_repositories_success(ac, mock_index_service): + """Test that endpoint returns list of Repositories.""" + mock_repos = [ + { + "id": str(uuid4()), + "name": "Repo 1", + "path_with_namespace": "group/repo1", + "web_url": "https://gitlab.com/group/repo1" + }, + { + "id": str(uuid4()), + "name": "Repo 2", + "path_with_namespace": "group/repo2", + "web_url": "https://gitlab.com/group/repo2" + } + ] + mock_index_service.list_repositories.return_value = mock_repos + + response = await ac.get(f"{BASE_URL}/list") + + assert response.status_code == 200 + + data = response.json() + assert len(data) == 2 + assert data[0]["name"] == "Repo 1" + assert data[0]["path_with_namespace"] == "group/repo1" + assert data[1]["name"] == "Repo 2" + assert data[1]["path_with_namespace"] == "group/repo2" + + mock_index_service.list_repositories.assert_called_once() + + +@pytest.mark.asyncio +async def test_list_gitlab_repositories_empty(ac, mock_index_service): + """Test that endpoint returns empty list when there are no repositories.""" + mock_index_service.list_repositories.return_value = [] + + response = await ac.get(f"{BASE_URL}/list") + + assert response.status_code == 200 + assert response.json() == [] diff --git a/tests/integration/test_gitlab_repo.py b/tests/integration/test_gitlab_repo.py new file mode 100644 index 0000000..8ae264b --- /dev/null +++ b/tests/integration/test_gitlab_repo.py @@ -0,0 +1,66 @@ + +import pytest + +from src.infrastructure.db.repositories.sqlalchemy_gitlab_repo import SqlAlchemyGitLabRepository + + +@pytest.fixture(scope="function") +def gitlab_repo(session): + """Return SqlAlchemyJobRepository's fixture for tests.""" + return SqlAlchemyGitLabRepository(session) + + +@pytest.mark.asyncio +async def test_save_config_creates_new(gitlab_repo): + """Test that service creates new config if it doesn't configured yet.""" + url = "https://gitlab.com" + token = "encrypted_token_123" + + result = await gitlab_repo.save_config(url, token) + + assert result.id == 1 + assert str(result.url) == url + "/" + assert result.private_token_encrypted == token + + saved_config = await gitlab_repo.get_config() + assert saved_config is not None + assert str(saved_config.url) == url + "/" + + +@pytest.mark.asyncio +async def test_save_config_updates_existing(gitlab_repo): + """Test that service updates old config.""" + await gitlab_repo.save_config("https://old.com", "old_token") + + new_url = "https://new-gitlab.com" + new_token = "new_secret_token" + + result = await gitlab_repo.save_config(new_url, new_token) + + assert result.id == 1 # singleton + assert str(result.url) == new_url + "/" + assert result.private_token_encrypted == new_token + + saved_config = await gitlab_repo.get_config() + assert str(saved_config.url) == new_url + "/" + assert saved_config.private_token_encrypted == new_token + + +@pytest.mark.asyncio +async def test_get_config_success(gitlab_repo): + """Test that service returns created config.""" + url = "https://exist.com" + await gitlab_repo.save_config(url, "token") + + result = await gitlab_repo.get_config() + + assert result is not None + assert result.id == 1 + assert str(result.url) == url + "/" + + +@pytest.mark.asyncio +async def test_get_config_not_found(gitlab_repo): + """Test that service returns None if config isn't configured yet.""" + result = await gitlab_repo.get_config() + assert result is None From 13ce4908c54c40faed5eac01990724e4003caac6 Mon Sep 17 00:00:00 2001 From: sardor014 Date: Mon, 29 Dec 2025 23:13:17 +0300 Subject: [PATCH 08/13] feat(backend): add tests for role repo --- tests/integration/test_role_repo.py | 101 ++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 tests/integration/test_role_repo.py diff --git a/tests/integration/test_role_repo.py b/tests/integration/test_role_repo.py new file mode 100644 index 0000000..2bc5556 --- /dev/null +++ b/tests/integration/test_role_repo.py @@ -0,0 +1,101 @@ +from uuid import uuid4 + +import pytest + +from src.domain.models.user import Role as DomainRole +from src.infrastructure.db.repositories.sqlalchemy_role_repo import SqlAlchemyRoleRepository + + +@pytest.fixture(scope="function") +def role_repo(session): + """Return SqlAlchemyRoleRepository's fixture for tests.""" + return SqlAlchemyRoleRepository(session) + + +@pytest.mark.asyncio +async def test_create_role_success(role_repo): + """Test that Role is created.""" + role_name = "test_role" + permissions = ["test1", "test2"] + new_role = DomainRole( + id=uuid4(), + name=role_name, + permissions=permissions + ) + + saved_role = await role_repo.create(new_role) + + assert saved_role is not None + assert saved_role.name == role_name + assert saved_role.permissions == permissions + assert saved_role.id is not None + + +@pytest.mark.asyncio +async def test_create_role_duplicate_name(role_repo): + """Test that Role is not created if it already exists.""" + role1 = DomainRole( + id=uuid4(), + name="test_role1", + permissions=[] + ) + await role_repo.create(role1) + + role2 = DomainRole( + id=uuid4(), + name="test_role1", # the same name + permissions=["other_perm"] + ) + + result = await role_repo.create(role2) + + assert result is None + + +@pytest.mark.asyncio +async def test_get_by_name_success(role_repo): + """Test that Role is extracted by name.""" + name = "test_name" + permissions = ["test_perm"] + await role_repo.create( + DomainRole(id=uuid4(), name=name, permissions=permissions) + ) + + found_role = await role_repo.get_by_name(name) + + assert found_role is not None + assert found_role.name == name + assert found_role.permissions == permissions + + +@pytest.mark.asyncio +async def test_get_by_name_not_found(role_repo): + """Test that Role is not extracted if it isn't exist.""" + result = await role_repo.get_by_name("test_role") + assert result is None + + +@pytest.mark.asyncio +async def test_get_all_roles_success(role_repo): + """Test that all Roles are extracted.""" + roles_to_create = ["test_1", "test_2", "test_3"] + for name in roles_to_create: + await role_repo.create( + DomainRole(id=uuid4(), name=name, permissions=[]) + ) + + all_roles = await role_repo.get_all_roles() + + assert len(all_roles) == 3 + + fetched_names = [r.name for r in all_roles] + for name in roles_to_create: + assert name in fetched_names + + +@pytest.mark.asyncio +async def test_get_all_roles_empty(role_repo): + """Test that an empty list is extracted if there are no Roles.""" + all_roles = await role_repo.get_all_roles() + + assert all_roles == [] From 69de2435657986cd6e72e750f99d3469829b90f1 Mon Sep 17 00:00:00 2001 From: sardor014 Date: Sat, 27 Dec 2025 08:03:13 +0300 Subject: [PATCH 09/13] feat(backend): setup and add dishka --- .../bc62d80962c0_init_full_structure.py | 89 ++++++++++++ pyproject.toml | 1 + src/api/dependencies.py | 71 +-------- src/api/routers/admin.py | 17 ++- src/api/routers/auth.py | 11 +- src/api/routers/chat.py | 24 +-- src/api/routers/repository.py | 64 +++++++- src/application/services/__init__.py | 4 + src/domain/repositories/__init__.py | 5 + .../db/repositories/__init__.py | 5 + src/infrastructure/di/__init__.py | 0 src/infrastructure/di/providers.py | 137 ++++++++++++++++++ src/main.py | 18 ++- 13 files changed, 352 insertions(+), 94 deletions(-) create mode 100644 alembic/versions/bc62d80962c0_init_full_structure.py create mode 100644 src/infrastructure/di/__init__.py create mode 100644 src/infrastructure/di/providers.py diff --git a/alembic/versions/bc62d80962c0_init_full_structure.py b/alembic/versions/bc62d80962c0_init_full_structure.py new file mode 100644 index 0000000..1252ae3 --- /dev/null +++ b/alembic/versions/bc62d80962c0_init_full_structure.py @@ -0,0 +1,89 @@ +"""init_full_structure + +Revision ID: bc62d80962c0 +Revises: +Create Date: 2025-12-27 04:47:16.058456 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'bc62d80962c0' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('gitlab_configs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('url', sa.String(), nullable=False), + sa.Column('private_token_encrypted', sa.Text(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('indexing_jobs', + sa.Column('id', sa.String(), nullable=False), + sa.Column('status', sa.String(length=50), nullable=False), + sa.Column('repository_ids', sa.JSON(), nullable=False), + sa.Column('details', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_indexing_jobs_status'), 'indexing_jobs', ['status'], unique=False) + op.create_table('roles', + sa.Column('name', sa.String(length=50), nullable=False), + sa.Column('permissions', sa.JSON(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_roles_name'), 'roles', ['name'], unique=True) + op.create_table('users', + sa.Column('username', sa.String(length=100), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('role', sa.String(length=50), nullable=False), + sa.Column('hashed_password', sa.String(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True) + op.create_table('chats', + sa.Column('title', sa.String(length=200), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('owner_id', sa.UUID(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('messages', + sa.Column('chat_id', sa.UUID(), nullable=False), + sa.Column('role', sa.String(length=20), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('sources', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.ForeignKeyConstraint(['chat_id'], ['chats.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('messages') + op.drop_table('chats') + op.drop_index(op.f('ix_users_username'), table_name='users') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + op.drop_index(op.f('ix_roles_name'), table_name='roles') + op.drop_table('roles') + op.drop_index(op.f('ix_indexing_jobs_status'), table_name='indexing_jobs') + op.drop_table('indexing_jobs') + op.drop_table('gitlab_configs') + # ### end Alembic commands ### diff --git a/pyproject.toml b/pyproject.toml index 6d55c35..39c2a23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "httpx>=0.27.0", "greenlet>=3.2.4", "pytest-mock>=3.15.1", + "dishka>=1.7.2", ] [tool.ruff] diff --git a/src/api/dependencies.py b/src/api/dependencies.py index b3a2388..26e1528 100644 --- a/src/api/dependencies.py +++ b/src/api/dependencies.py @@ -1,84 +1,21 @@ import uuid from typing import Optional +from dishka.integrations.fastapi import FromDishka, inject from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer -from sqlalchemy.ext.asyncio import AsyncSession -from src.application.services.admin_service import AdminService -from src.application.services.auth_service import AuthService -from src.application.services.chat_service import ChatService -from src.application.services.index_service import IndexService from src.domain.models.user import User as DomainUser -from src.domain.repositories.chat_repo import IChatRepository -from src.domain.repositories.gitlab_repo import IGitLabRepository -from src.domain.repositories.job_repo import IJobRepository -from src.domain.repositories.role_repo import IRoleRepository from src.domain.repositories.user_repo import IUserRepository -from src.infrastructure.db.repositories.sqlalchemy_chat_repo import SqlAlchemyChatRepository -from src.infrastructure.db.repositories.sqlalchemy_gitlab_repo import SqlAlchemyGitLabRepository -from src.infrastructure.db.repositories.sqlalchemy_job_repo import SqlAlchemyJobRepository -from src.infrastructure.db.repositories.sqlalchemy_role_repo import SqlAlchemyRoleRepository -from src.infrastructure.db.repositories.sqlalchemy_user_repo import SqlAlchemyUserRepository -from src.infrastructure.db.session import get_db_session from src.infrastructure.security.jwt import decode_access_token oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/auth/token") -def get_user_repository(db: AsyncSession = Depends(get_db_session)) -> IUserRepository: - """Get user's repository.""" - return SqlAlchemyUserRepository(session=db) - - -def get_auth_service(user_repo: IUserRepository = Depends(get_user_repository)) -> AuthService: - """Get auth service.""" - return AuthService(user_repo=user_repo) - - -def get_chat_repository(db: AsyncSession = Depends(get_db_session)) -> IChatRepository: - """Get chat's repository.""" - return SqlAlchemyChatRepository(session=db) - - -def get_chat_service(chat_repo: IUserRepository = Depends(get_chat_repository)) -> ChatService: - """Get chat's service.""" - return ChatService(chat_repo=chat_repo) - - -def get_role_repository(db: AsyncSession = Depends(get_db_session)) -> IRoleRepository: - """Get roles' repository.""" - return SqlAlchemyRoleRepository(session=db) - - -def get_gitlab_repository(db: AsyncSession = Depends(get_db_session)) -> IGitLabRepository: - """Get GitLab's repository.""" - return SqlAlchemyGitLabRepository(session=db) - - -def get_job_service(db: AsyncSession = Depends(get_db_session)) -> IJobRepository: - """Get job service.""" - return SqlAlchemyJobRepository(session=db) - - -def get_index_service( - gitlab_repo: IGitLabRepository = Depends(get_gitlab_repository), - job_repo: IJobRepository = Depends(get_job_service) -) -> IndexService: - """Get index service.""" - return IndexService(gitlab_repo=gitlab_repo, job_repo=job_repo) - - -def get_admin_service( - user_repo: IUserRepository = Depends(get_user_repository), - role_repo: IRoleRepository = Depends(get_role_repository), -) -> AdminService: - """Get admin service.""" - return AdminService(user_repo=user_repo, role_repo=role_repo) - - +@inject async def get_current_user( - token: str = Depends(oauth2_scheme), user_repo: IUserRepository = Depends(get_user_repository) + user_repo: FromDishka[IUserRepository], + token: str = Depends(oauth2_scheme), ) -> DomainUser: """Decode JWT token and find user in database.""" credentials_exception = HTTPException( diff --git a/src/api/routers/admin.py b/src/api/routers/admin.py index 273ad2b..0b4159e 100644 --- a/src/api/routers/admin.py +++ b/src/api/routers/admin.py @@ -1,23 +1,27 @@ from typing import List +from dishka.integrations.fastapi import DishkaRoute, FromDishka from fastapi import APIRouter, Depends, status from pydantic import UUID4 -from src.api.dependencies import get_admin_service, get_current_admin_user +from src.api.dependencies import get_current_admin_user from src.api.schemas.admin import RoleCreate, RoleResponse, UserResponse, UserRoleUpdate from src.application.services.admin_service import AdminService -router_admin = APIRouter(dependencies=[Depends(get_current_admin_user)]) +router_admin = APIRouter( + dependencies=[Depends(get_current_admin_user)], + route_class=DishkaRoute +) @router_admin.get("/users", response_model=List[UserResponse]) -async def get_all_users(admin_service: AdminService = Depends(get_admin_service)): +async def get_all_users(admin_service: FromDishka[AdminService]): """Get all users from database.""" return await admin_service.get_all_users() @router_admin.get("/roles", response_model=List[RoleResponse]) -async def get_all_roles(admin_service: AdminService = Depends(get_admin_service)): +async def get_all_roles(admin_service: FromDishka[AdminService]): """Get all roles from database.""" return await admin_service.get_all_roles() @@ -26,7 +30,7 @@ async def get_all_roles(admin_service: AdminService = Depends(get_admin_service) async def update_user_role( user_id: UUID4, role_update: UserRoleUpdate, - admin_service: AdminService = Depends(get_admin_service), + admin_service: FromDishka[AdminService], ): """Update user role.""" return await admin_service.update_user_role(user_id, role_update.role) @@ -34,7 +38,8 @@ async def update_user_role( @router_admin.post("/roles", status_code=status.HTTP_201_CREATED) async def create_new_role( - role_create: RoleCreate, admin_service: AdminService = Depends(get_admin_service) + role_create: RoleCreate, + admin_service: FromDishka[AdminService] ): """Create new custom role.""" return await admin_service.create_new_role(role_create) diff --git a/src/api/routers/auth.py b/src/api/routers/auth.py index cb6db82..cccaed2 100644 --- a/src/api/routers/auth.py +++ b/src/api/routers/auth.py @@ -1,12 +1,13 @@ +from dishka.integrations.fastapi import DishkaRoute, FromDishka from fastapi import APIRouter, Depends from fastapi.security import OAuth2PasswordRequestForm -from src.api.dependencies import get_auth_service, get_current_user +from src.api.dependencies import get_current_user from src.api.schemas.auth import Token, UserRegistration, UserResponse from src.application.services.auth_service import AuthService from src.domain.models.user import User -router = APIRouter() +router = APIRouter(route_class=DishkaRoute) @router.post( @@ -14,8 +15,8 @@ response_model=Token, ) async def login_for_access_token( - form_data: OAuth2PasswordRequestForm = Depends(), - auth_service: AuthService = Depends(get_auth_service), + auth_service: FromDishka[AuthService], + form_data: OAuth2PasswordRequestForm = Depends() ): """Authenticate user by login and password and return a JWT token.""" token_data = await auth_service.authenticate_user( @@ -27,7 +28,7 @@ async def login_for_access_token( @router.post("/register", response_model=UserResponse) async def register( - user_create: UserRegistration, auth_service: AuthService = Depends(get_auth_service) + user_create: UserRegistration, auth_service: FromDishka[AuthService] ): """Register new user.""" created_user = await auth_service.register_new_user(user_create) diff --git a/src/api/routers/chat.py b/src/api/routers/chat.py index 3ecab51..6e99223 100644 --- a/src/api/routers/chat.py +++ b/src/api/routers/chat.py @@ -1,9 +1,10 @@ from typing import List +from dishka.integrations.fastapi import DishkaRoute, FromDishka from fastapi import APIRouter, Depends, status from pydantic import UUID4 -from src.api.dependencies import get_chat_service, get_current_user +from src.api.dependencies import get_current_user from src.api.schemas.chat import ( ChatBase, ChatHistoryResponse, @@ -14,7 +15,10 @@ from src.application.services.chat_service import ChatService from src.domain.models.user import User -router_chat = APIRouter(dependencies=[Depends(get_current_user)]) +router_chat = APIRouter( + dependencies=[Depends(get_current_user)], + route_class=DishkaRoute +) @router_chat.post( @@ -24,8 +28,8 @@ ) async def create_new_chat( chat_data: ChatBase, - current_user: User = Depends(get_current_user), - service: ChatService = Depends(get_chat_service) + service: FromDishka[ChatService], + current_user: User = Depends(get_current_user) ): """Create new chat with a title.""" return await service.create_chat( @@ -36,8 +40,8 @@ async def create_new_chat( @router_chat.get("/", response_model=List[ChatResponse]) async def get_user_chats( - current_user: User = Depends(get_current_user), - service: ChatService = Depends(get_chat_service) + service: FromDishka[ChatService], + current_user: User = Depends(get_current_user) ): """Get all chat for a specific user.""" return await service.get_user_chats(current_user.id) @@ -46,8 +50,8 @@ async def get_user_chats( @router_chat.get("/{chat_id}", response_model=ChatHistoryResponse) async def get_chat_history( chat_id: UUID4, - current_user: User = Depends(get_current_user), - service: ChatService = Depends(get_chat_service) + service: FromDishka[ChatService], + current_user: User = Depends(get_current_user) ): """Get chat history by chat_id.""" return await service.get_chat_history( @@ -63,8 +67,8 @@ async def get_chat_history( async def send( chat_id: UUID4, message: MessageCreate, - current_user: User = Depends(get_current_user), - service: ChatService = Depends(get_chat_service) + service: FromDishka[ChatService], + current_user: User = Depends(get_current_user) ): """QnA iteration. diff --git a/src/api/routers/repository.py b/src/api/routers/repository.py index eb5a497..5fa57c4 100644 --- a/src/api/routers/repository.py +++ b/src/api/routers/repository.py @@ -1,21 +1,25 @@ from typing import List -from fastapi import APIRouter, Depends, status +from dishka.integrations.fastapi import DishkaRoute, FromDishka +from fastapi import APIRouter, Depends, HTTPException, status -from src.api.dependencies import get_current_admin_user, get_index_service +from src.api.dependencies import get_current_admin_user from src.api.schemas.repository import ( GitLabConfigCreate, Repository, ) from src.application.services.index_service import IndexService -router_repository = APIRouter(dependencies=[Depends(get_current_admin_user)]) +router_repository = APIRouter( + dependencies=[Depends(get_current_admin_user)], + route_class=DishkaRoute +) @router_repository.post("/config", status_code=status.HTTP_202_ACCEPTED) async def configure_gitlab( config_data: GitLabConfigCreate, - service: IndexService = Depends(get_index_service) + service: FromDishka[IndexService] ): """Save GitLab instans' link and private token.""" return await service.configure_gitlab( @@ -26,7 +30,57 @@ async def configure_gitlab( @router_repository.get("/list", response_model=List[Repository]) async def list_gitlab_repositories( - service: IndexService = Depends(get_index_service) + service: FromDishka[IndexService] ): """Get list of repositories that are available for indexing.""" return await service.list_repositories() +<<<<<<< HEAD +======= + + +@router_repository.post("/trigger", response_model=IndexingJob) +async def trigger_indexing( + sync_request: SyncRequest, + service: FromDishka[IndexService] +): + """Start an indexing of repositories by their ids.""" + return await service.trigger_indexing( + repository_ids=sync_request.repository_ids + ) + +@router_repository.delete("/{job_id}") +async def delete_indexing_job( + job_id: str, + service: FromDishka[IndexService] +): + """Delete an existing job by its id. + + Return true if deleted, false if the job doesn't exist. + """ + return await service.delete_indexind_job(job_id) + +@router_repository.get("/status/{job_id}", response_model=IndexingJob) +async def get_indexing_status( + job_id: str, + service: FromDishka[IndexService] +): + """Get status of indexing job by its id.""" + return await service.get_indexing_status(job_id) + + +@router_repository.put("/status/{job_id}", response_model=IndexingJob) +async def update_indexing_status( + job_id: str, + status_update: JobStatusUpdate, + service: FromDishka[IndexService] +): + """Update a status of an existing job by its id.""" + updated_job = await service.update_indexing_status(job_id, status_update) + if not updated_job: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Job with id {job_id} not found" + ) + + return updated_job +>>>>>>> 4ebc500 (feat(backend): setup and add dishka) diff --git a/src/application/services/__init__.py b/src/application/services/__init__.py index e69de29..61719cb 100644 --- a/src/application/services/__init__.py +++ b/src/application/services/__init__.py @@ -0,0 +1,4 @@ +from src.application.services.admin_service import AdminService +from src.application.services.auth_service import AuthService +from src.application.services.chat_service import ChatService +from src.application.services.index_service import IndexService diff --git a/src/domain/repositories/__init__.py b/src/domain/repositories/__init__.py index e69de29..be16342 100644 --- a/src/domain/repositories/__init__.py +++ b/src/domain/repositories/__init__.py @@ -0,0 +1,5 @@ +from src.domain.repositories.chat_repo import IChatRepository +from src.domain.repositories.gitlab_repo import IGitLabRepository +from src.domain.repositories.job_repo import IJobRepository +from src.domain.repositories.role_repo import IRoleRepository +from src.domain.repositories.user_repo import IUserRepository diff --git a/src/infrastructure/db/repositories/__init__.py b/src/infrastructure/db/repositories/__init__.py index e69de29..bff60f4 100644 --- a/src/infrastructure/db/repositories/__init__.py +++ b/src/infrastructure/db/repositories/__init__.py @@ -0,0 +1,5 @@ +from src.infrastructure.db.repositories.sqlalchemy_chat_repo import SqlAlchemyChatRepository +from src.infrastructure.db.repositories.sqlalchemy_gitlab_repo import SqlAlchemyGitLabRepository +from src.infrastructure.db.repositories.sqlalchemy_job_repo import SqlAlchemyJobRepository +from src.infrastructure.db.repositories.sqlalchemy_role_repo import SqlAlchemyRoleRepository +from src.infrastructure.db.repositories.sqlalchemy_user_repo import SqlAlchemyUserRepository diff --git a/src/infrastructure/di/__init__.py b/src/infrastructure/di/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/infrastructure/di/providers.py b/src/infrastructure/di/providers.py new file mode 100644 index 0000000..c9f7569 --- /dev/null +++ b/src/infrastructure/di/providers.py @@ -0,0 +1,137 @@ +import logging +from typing import AsyncIterable + +from dishka import Provider, Scope, provide +from fastapi import HTTPException, status +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine + +from src.application.services import AdminService, AuthService, ChatService, IndexService +from src.core.settings import Settings, settings +from src.domain.repositories import ( + IChatRepository, + IGitLabRepository, + IJobRepository, + IRoleRepository, + IUserRepository, +) +from src.infrastructure.db.repositories import ( + SqlAlchemyChatRepository, + SqlAlchemyGitLabRepository, + SqlAlchemyJobRepository, + SqlAlchemyRoleRepository, + SqlAlchemyUserRepository, +) + +logger = logging.getLogger(__name__) + + +class InfrastructureProvider(Provider): + + """Provider for core infrastructure components.""" + + @provide(scope=Scope.APP) + def get_settings(self) -> Settings: + """Get project settings.""" + return settings + + @provide(scope=Scope.APP) + def get_engine(self, settings: Settings) -> AsyncEngine: + """Get AsyncEngine.""" + return create_async_engine( + settings.DATABASE_URL.get_secret_value(), + echo=False, + pool_pre_ping=False + ) + + @provide(scope=Scope.REQUEST) + async def get_session(self, engine: AsyncEngine) -> AsyncIterable[AsyncSession]: + """Get database session.""" + async with AsyncSession(bind=engine, expire_on_commit=False) as session: + try: + yield session + await session.commit() + except SQLAlchemyError as sql_exc: + await session.rollback() + logger.error(f"SQLAlchemy error: {sql_exc}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Database error" + ) from sql_exc + except HTTPException as http_exc: + await session.rollback() + raise http_exc + except Exception as e: + await session.rollback() + logger.error(f"Error: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal server error" + ) from e + + +class RepositoryProvider(Provider): + + """Provider for Repositories.""" + + scope = Scope.REQUEST + + @provide + def get_user_repository(self, session: AsyncSession) -> IUserRepository: + """Get user's repository.""" + return SqlAlchemyUserRepository(session=session) + + @provide + def get_chat_repository(self, session: AsyncSession) -> IChatRepository: + """Get chat's repository.""" + return SqlAlchemyChatRepository(session=session) + + @provide + def get_role_repository(self, session: AsyncSession) -> IRoleRepository: + """Get roles' repository.""" + return SqlAlchemyRoleRepository(session=session) + + @provide + def get_gitlab_repository(self, session: AsyncSession) -> IGitLabRepository: + """Get GitLab's repository.""" + return SqlAlchemyGitLabRepository(session=session) + + @provide + def get_job_repository(self, session: AsyncSession) -> IJobRepository: + """Get job service.""" + return SqlAlchemyJobRepository(session=session) + + +class SericeProvider(Provider): + + """Provider for Services.""" + + scope = Scope.REQUEST + + @provide + def get_auth_service(self, user_repo: IUserRepository) -> AuthService: + """Get auth service.""" + return AuthService(user_repo=user_repo) + + @provide + def get_chat_service(self, chat_repo: IChatRepository) -> ChatService: + """Get chat's service.""" + return ChatService(chat_repo=chat_repo) + + @provide + def get_index_service( + self, + gitlab_repo: IGitLabRepository, + job_repo: IJobRepository + ) -> IndexService: + """Get index service.""" + return IndexService(gitlab_repo=gitlab_repo, job_repo=job_repo) + + @provide + def get_admin_service( + self, + user_repo: IUserRepository, + role_repo: IRoleRepository, + ) -> AdminService: + """Get admin service.""" + return AdminService(user_repo=user_repo, role_repo=role_repo) diff --git a/src/main.py b/src/main.py index e523965..79a6543 100644 --- a/src/main.py +++ b/src/main.py @@ -1,12 +1,28 @@ +from dishka import make_async_container +from dishka.integrations.fastapi import setup_dishka from fastapi import FastAPI from api.routers import admin, auth, chat, indexing, repository +from api.routers import admin, auth, chat, repository +from src.infrastructure.di.providers import ( + InfrastructureProvider, + RepositoryProvider, + SericeProvider, +) app = FastAPI( - title="Скелет", + title="Автономная LLM-система верифицируемого знания", version="0.1.0", ) +container = make_async_container( + InfrastructureProvider(), + RepositoryProvider(), + SericeProvider(), +) + +setup_dishka(container, app) + app.include_router(auth.router, prefix="/v1/auth") app.include_router(chat.router_chat, prefix="/v1/chat") app.include_router(admin.router_admin, prefix="/v1/admin") From 63f118053594bfc33fc5d302466bc5e25f2bd9fa Mon Sep 17 00:00:00 2001 From: sardor014 Date: Sun, 21 Dec 2025 18:32:34 +0300 Subject: [PATCH 10/13] feat(backend): add tests for admin router and admin service --- pyproject.toml | 5 +++++ tests/unit/test_admin_service.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 39c2a23..aed62e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,11 @@ dependencies = [ "greenlet>=3.2.4", "pytest-mock>=3.15.1", "dishka>=1.7.2", + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "httpx>=0.27.0", + "greenlet>=3.2.4", + "pytest-mock>=3.15.1", ] [tool.ruff] diff --git a/tests/unit/test_admin_service.py b/tests/unit/test_admin_service.py index 17c3b88..9ba2c71 100644 --- a/tests/unit/test_admin_service.py +++ b/tests/unit/test_admin_service.py @@ -189,7 +189,7 @@ async def test_create_new_role_success( permissions=permissions ) - mocker.patch("uuid.uuid4", return_value=fixed_uuid) + mocker.patch("src.infrastructure.db.models.base.uuid.uuid4", return_value=fixed_uuid) mock_role_repo.get_by_name.return_value = None mock_role_repo.create.return_value = new_role From 078568835fb0f44a8eb40af9fbfcdd7a46cf77e9 Mon Sep 17 00:00:00 2001 From: sardor014 Date: Sun, 28 Dec 2025 23:40:27 +0300 Subject: [PATCH 11/13] feat(backend): add tests for index router and index service --- src/api/routers/repository.py | 50 ----------------------------------- src/main.py | 1 - 2 files changed, 51 deletions(-) diff --git a/src/api/routers/repository.py b/src/api/routers/repository.py index 5fa57c4..493dcce 100644 --- a/src/api/routers/repository.py +++ b/src/api/routers/repository.py @@ -34,53 +34,3 @@ async def list_gitlab_repositories( ): """Get list of repositories that are available for indexing.""" return await service.list_repositories() -<<<<<<< HEAD -======= - - -@router_repository.post("/trigger", response_model=IndexingJob) -async def trigger_indexing( - sync_request: SyncRequest, - service: FromDishka[IndexService] -): - """Start an indexing of repositories by their ids.""" - return await service.trigger_indexing( - repository_ids=sync_request.repository_ids - ) - -@router_repository.delete("/{job_id}") -async def delete_indexing_job( - job_id: str, - service: FromDishka[IndexService] -): - """Delete an existing job by its id. - - Return true if deleted, false if the job doesn't exist. - """ - return await service.delete_indexind_job(job_id) - -@router_repository.get("/status/{job_id}", response_model=IndexingJob) -async def get_indexing_status( - job_id: str, - service: FromDishka[IndexService] -): - """Get status of indexing job by its id.""" - return await service.get_indexing_status(job_id) - - -@router_repository.put("/status/{job_id}", response_model=IndexingJob) -async def update_indexing_status( - job_id: str, - status_update: JobStatusUpdate, - service: FromDishka[IndexService] -): - """Update a status of an existing job by its id.""" - updated_job = await service.update_indexing_status(job_id, status_update) - if not updated_job: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Job with id {job_id} not found" - ) - - return updated_job ->>>>>>> 4ebc500 (feat(backend): setup and add dishka) diff --git a/src/main.py b/src/main.py index 79a6543..f3bc0a2 100644 --- a/src/main.py +++ b/src/main.py @@ -3,7 +3,6 @@ from fastapi import FastAPI from api.routers import admin, auth, chat, indexing, repository -from api.routers import admin, auth, chat, repository from src.infrastructure.di.providers import ( InfrastructureProvider, RepositoryProvider, From 711d6f8af80c2774fdfc98f0ac3db028234b9e43 Mon Sep 17 00:00:00 2001 From: sardor014 Date: Wed, 31 Dec 2025 12:17:59 +0300 Subject: [PATCH 12/13] feat(backend): change default depends to dishka in tests --- src/api/routers/indexing.py | 16 +++++---- tests/api/test_admin_router.py | 51 ++++++++++++++++------------ tests/api/test_auth_router.py | 49 ++++++++++++++++----------- tests/api/test_chat_router.py | 48 +++++++++++++++----------- tests/api/test_indexing_router.py | 52 +++++++++++++++++------------ tests/api/test_repository_router.py | 51 ++++++++++++++++------------ tests/conftest.py | 8 +++++ 7 files changed, 165 insertions(+), 110 deletions(-) diff --git a/src/api/routers/indexing.py b/src/api/routers/indexing.py index 9812ba8..e221576 100644 --- a/src/api/routers/indexing.py +++ b/src/api/routers/indexing.py @@ -1,7 +1,8 @@ +from dishka.integrations.fastapi import DishkaRoute, FromDishka from fastapi import APIRouter, Depends, HTTPException, status -from src.api.dependencies import get_current_admin_user, get_index_service +from src.api.dependencies import get_current_admin_user from src.api.schemas.repository import ( IndexingJob, JobStatusUpdate, @@ -9,13 +10,16 @@ ) from src.application.services.index_service import IndexService -router_indexing = APIRouter(dependencies=[Depends(get_current_admin_user)]) +router_indexing = APIRouter( + dependencies=[Depends(get_current_admin_user)], + route_class=DishkaRoute +) @router_indexing.post("/trigger", response_model=IndexingJob) async def trigger_indexing( sync_request: SyncRequest, - service: IndexService = Depends(get_index_service) + service: FromDishka[IndexService] ): """Start an indexing of repositories by their ids.""" return await service.trigger_indexing( @@ -26,7 +30,7 @@ async def trigger_indexing( @router_indexing.delete("/{job_id}") async def delete_indexing_job( job_id: str, - service: IndexService = Depends(get_index_service) + service: FromDishka[IndexService] ): """Delete an existing job by its id. @@ -38,7 +42,7 @@ async def delete_indexing_job( @router_indexing.get("/status/{job_id}", response_model=IndexingJob) async def get_indexing_status( job_id: str, - service: IndexService = Depends(get_index_service) + service: FromDishka[IndexService] ): """Get status of indexing job by its id.""" return await service.get_indexing_status(job_id) @@ -48,7 +52,7 @@ async def get_indexing_status( async def update_indexing_status( job_id: str, status_update: JobStatusUpdate, - service: IndexService = Depends(get_index_service) + service: FromDishka[IndexService] ): """Update a status of an existing job by its id.""" updated_job = await service.update_indexing_status(job_id, status_update) diff --git a/tests/api/test_admin_router.py b/tests/api/test_admin_router.py index f2cfd09..4b22585 100644 --- a/tests/api/test_admin_router.py +++ b/tests/api/test_admin_router.py @@ -3,28 +3,18 @@ import pytest import pytest_asyncio +from dishka import Provider, Scope, make_async_container +from dishka.integrations.fastapi import setup_dishka from fastapi import HTTPException, status -from fastapi.testclient import TestClient from httpx import ASGITransport, AsyncClient -from src.api.dependencies import get_admin_service, get_current_admin_user +from src.api.dependencies import get_current_admin_user from src.application.services.admin_service import AdminService from src.domain.models.user import Role as DomainRole, User as DomainUser -from src.main import app - -client = TestClient(app) - BASE_URL = "/v1/admin" -@pytest_asyncio.fixture(scope="function") -async def ac(): - """Create AsyncClient for tests.""" - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: - yield c - - @pytest_asyncio.fixture def mock_admin_service(): """Create mock for AdminSevice.""" @@ -32,10 +22,30 @@ def mock_admin_service(): return service -@pytest_asyncio.fixture(autouse=True) -def override_dependencies(mock_admin_service): - """Override dependencies for AdminService's mock.""" - app.dependency_overrides[get_current_admin_user] = lambda: DomainUser( +@pytest_asyncio.fixture(scope="function") +async def dishka_app(app_fixture, mock_admin_service): + """Fixture for Dishka integration.""" + provider = Provider() + + provider.provide( + lambda: mock_admin_service, + scope=Scope.APP, + provides=AdminService + ) + + container = make_async_container(provider) + app_fixture.middleware_stack = None + setup_dishka(container, app_fixture) + + yield app_fixture + + await container.close() + + +@pytest_asyncio.fixture(scope="function") +async def ac(dishka_app): + """Create AsyncClient for tests.""" + dishka_app.dependency_overrides[get_current_admin_user] = lambda: DomainUser( id=uuid4(), username="admin", email="admin@test.com", @@ -43,11 +53,10 @@ def override_dependencies(mock_admin_service): hashed_password="hash" ) - app.dependency_overrides[get_admin_service] = lambda: mock_admin_service - - yield + async with AsyncClient(transport=ASGITransport(app=dishka_app), base_url="http://test") as c: + yield c - app.dependency_overrides = {} + dishka_app.dependency_overrides = {} @pytest.mark.asyncio diff --git a/tests/api/test_auth_router.py b/tests/api/test_auth_router.py index 6c9ea01..7318215 100644 --- a/tests/api/test_auth_router.py +++ b/tests/api/test_auth_router.py @@ -3,31 +3,20 @@ import pytest import pytest_asyncio +from dishka import Provider, Scope, make_async_container +from dishka.integrations.fastapi import setup_dishka from fastapi import HTTPException, status -from fastapi.testclient import TestClient from httpx import ASGITransport, AsyncClient from src.api.dependencies import ( - get_auth_service, get_current_user, ) from src.application.services.auth_service import AuthService from src.domain.models.user import User as DomainUser -from src.main import app - -client = TestClient(app) - BASE_URL = "/v1/auth" -@pytest_asyncio.fixture(scope="function") -async def ac(): - """Create AsyncClient for tests.""" - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: - yield c - - @pytest_asyncio.fixture def mock_auth_service(): """Create mock for AuthSevice.""" @@ -35,10 +24,30 @@ def mock_auth_service(): return service -@pytest_asyncio.fixture(autouse=True) -def override_dependencies(mock_auth_service): - """Override dependencies for AuthService's mock.""" - app.dependency_overrides[get_current_user] = lambda: DomainUser( +@pytest_asyncio.fixture(scope="function") +async def dishka_app(app_fixture, mock_auth_service): + """Fixture for Dishka integration.""" + provider = Provider() + + provider.provide( + lambda: mock_auth_service, + scope=Scope.APP, + provides=AuthService + ) + + container = make_async_container(provider) + app_fixture.middleware_stack = None + setup_dishka(container, app_fixture) + + yield app_fixture + + await container.close() + + +@pytest_asyncio.fixture(scope="function") +async def ac(dishka_app): + """Create AsyncClient for tests.""" + dishka_app.dependency_overrides[get_current_user] = lambda: DomainUser( id=uuid4(), username="user", email="user@test.com", @@ -46,11 +55,11 @@ def override_dependencies(mock_auth_service): hashed_password="hash" ) - app.dependency_overrides[get_auth_service] = lambda: mock_auth_service + async with AsyncClient(transport=ASGITransport(app=dishka_app), base_url="http://test") as c: + yield c - yield + dishka_app.dependency_overrides = {} - app.dependency_overrides = {} @pytest.mark.asyncio async def test_register_user_success(ac, mock_auth_service): diff --git a/tests/api/test_chat_router.py b/tests/api/test_chat_router.py index b5fe2d0..0ca7081 100644 --- a/tests/api/test_chat_router.py +++ b/tests/api/test_chat_router.py @@ -4,32 +4,21 @@ import pytest import pytest_asyncio +from dishka import Provider, Scope, make_async_container +from dishka.integrations.fastapi import setup_dishka from fastapi import HTTPException, status -from fastapi.testclient import TestClient from httpx import ASGITransport, AsyncClient from src.api.dependencies import ( - get_chat_service, get_current_user, ) from src.application.services.chat_service import ChatService from src.domain.models.chat import Chat as DomainChat, Message as DomainMessage from src.domain.models.user import User as DomainUser -from src.main import app - -client = TestClient(app) - BASE_URL = "/v1/chat" -@pytest_asyncio.fixture(scope="function") -async def ac(): - """Create AsyncClient for tests.""" - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: - yield c - - @pytest_asyncio.fixture def mock_chat_service(): """Create mock for ChatSevice.""" @@ -49,16 +38,35 @@ def mock_user(): ) -@pytest_asyncio.fixture(autouse=True) -def override_dependencies(mock_chat_service, mock_user): - """Override dependencies for ChatService's mock.""" - app.dependency_overrides[get_current_user] = lambda: mock_user +@pytest_asyncio.fixture(scope="function") +async def dishka_app(app_fixture, mock_chat_service): + """Fixture for Dishka integration.""" + provider = Provider() + + provider.provide( + lambda: mock_chat_service, + scope=Scope.APP, + provides=ChatService + ) + + container = make_async_container(provider) + app_fixture.middleware_stack = None + setup_dishka(container, app_fixture) + + yield app_fixture + + await container.close() - app.dependency_overrides[get_chat_service] = lambda: mock_chat_service - yield +@pytest_asyncio.fixture(scope="function") +async def ac(dishka_app, mock_user): + """Create AsyncClient for tests.""" + dishka_app.dependency_overrides[get_current_user] = lambda: mock_user + + async with AsyncClient(transport=ASGITransport(app=dishka_app), base_url="http://test") as c: + yield c - app.dependency_overrides = {} + dishka_app.dependency_overrides = {} @pytest.mark.asyncio diff --git a/tests/api/test_indexing_router.py b/tests/api/test_indexing_router.py index d1ca41b..01fa8b3 100644 --- a/tests/api/test_indexing_router.py +++ b/tests/api/test_indexing_router.py @@ -4,29 +4,19 @@ import pytest import pytest_asyncio -from fastapi.testclient import TestClient +from dishka import Provider, Scope, make_async_container +from dishka.integrations.fastapi import setup_dishka from httpx import ASGITransport, AsyncClient -from src.api.dependencies import get_admin_service, get_current_admin_user, get_index_service +from src.api.dependencies import get_current_admin_user from src.application.services.admin_service import AdminService from src.application.services.index_service import IndexService from src.domain.models.knowledge import JobStatus from src.domain.models.user import User as DomainUser -from src.main import app - -client = TestClient(app) - BASE_URL = "/v1/indexing" -@pytest_asyncio.fixture(scope="function") -async def ac(): - """Create AsyncClient for tests.""" - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: - yield c - - @pytest_asyncio.fixture def mock_admin_service(): """Create mock for AdminSevice.""" @@ -41,10 +31,30 @@ def mock_index_service(): return service -@pytest_asyncio.fixture(autouse=True) -def override_dependencies(mock_admin_service, mock_index_service): - """Override dependencies for AdminService's mock.""" - app.dependency_overrides[get_current_admin_user] = lambda: DomainUser( +@pytest_asyncio.fixture(scope="function") +async def dishka_app(app_fixture, mock_index_service): + """Fixture for Dishka integration.""" + provider = Provider() + + provider.provide( + lambda: mock_index_service, + scope=Scope.APP, + provides=IndexService + ) + + container = make_async_container(provider) + app_fixture.middleware_stack = None + setup_dishka(container, app_fixture) + + yield app_fixture + + await container.close() + + +@pytest_asyncio.fixture(scope="function") +async def ac(dishka_app): + """Create AsyncClient for tests.""" + dishka_app.dependency_overrides[get_current_admin_user] = lambda: DomainUser( id=uuid4(), username="admin", email="admin@test.com", @@ -52,12 +62,10 @@ def override_dependencies(mock_admin_service, mock_index_service): hashed_password="hash" ) - app.dependency_overrides[get_admin_service] = lambda: mock_admin_service - app.dependency_overrides[get_index_service] = lambda: mock_index_service - - yield + async with AsyncClient(transport=ASGITransport(app=dishka_app), base_url="http://test") as c: + yield c - app.dependency_overrides = {} + dishka_app.dependency_overrides = {} @pytest.mark.asyncio diff --git a/tests/api/test_repository_router.py b/tests/api/test_repository_router.py index cc41083..686dfb2 100644 --- a/tests/api/test_repository_router.py +++ b/tests/api/test_repository_router.py @@ -3,27 +3,17 @@ import pytest import pytest_asyncio -from fastapi.testclient import TestClient +from dishka import Provider, Scope, make_async_container +from dishka.integrations.fastapi import setup_dishka from httpx import ASGITransport, AsyncClient -from src.api.dependencies import get_current_admin_user, get_index_service +from src.api.dependencies import get_current_admin_user from src.application.services.index_service import IndexService from src.domain.models.user import User as DomainUser -from src.main import app - -client = TestClient(app) - BASE_URL = "/v1/repository" -@pytest_asyncio.fixture(scope="function") -async def ac(): - """Create AsyncClient for tests.""" - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: - yield c - - @pytest_asyncio.fixture def mock_index_service(): """Create mock for IndexSevice.""" @@ -31,10 +21,30 @@ def mock_index_service(): return service -@pytest_asyncio.fixture(autouse=True) -def override_dependencies(mock_index_service): - """Override dependencies for IndexService's mock.""" - app.dependency_overrides[get_current_admin_user] = lambda: DomainUser( +@pytest_asyncio.fixture(scope="function") +async def dishka_app(app_fixture, mock_index_service): + """Fixture for Dishka integration.""" + provider = Provider() + + provider.provide( + lambda: mock_index_service, + scope=Scope.APP, + provides=IndexService + ) + + container = make_async_container(provider) + app_fixture.middleware_stack = None + setup_dishka(container, app_fixture) + + yield app_fixture + + await container.close() + + +@pytest_asyncio.fixture(scope="function") +async def ac(dishka_app): + """Create AsyncClient for tests.""" + dishka_app.dependency_overrides[get_current_admin_user] = lambda: DomainUser( id=uuid4(), username="admin", email="admin@test.com", @@ -42,11 +52,10 @@ def override_dependencies(mock_index_service): hashed_password="hash" ) - app.dependency_overrides[get_index_service] = lambda: mock_index_service - - yield + async with AsyncClient(transport=ASGITransport(app=dishka_app), base_url="http://test") as c: + yield c - app.dependency_overrides = {} + dishka_app.dependency_overrides = {} @pytest.mark.asyncio diff --git a/tests/conftest.py b/tests/conftest.py index 9af9759..eac68e7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ from src.core.settings import settings from src.infrastructure.db.models.base import Base +from src.main import app TEST_DATABASE_URL = settings.TEST_DATABASE_URL.get_secret_value() @@ -30,3 +31,10 @@ async def session(db_engine): async with async_session_factory() as session: yield session + + +@pytest_asyncio.fixture(scope="function") +def app_fixture(): + """Create a fixture for app.""" + app.dependency_overrides = {} + return app From 7ffad8ebf12ec67566ed4fe9a82e7210b3812e11 Mon Sep 17 00:00:00 2001 From: sardor014 Date: Wed, 31 Dec 2025 12:24:05 +0300 Subject: [PATCH 13/13] feat(backend): fix merge conflicts --- src/api/routers/repository.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/routers/repository.py b/src/api/routers/repository.py index 493dcce..2259711 100644 --- a/src/api/routers/repository.py +++ b/src/api/routers/repository.py @@ -1,7 +1,7 @@ from typing import List from dishka.integrations.fastapi import DishkaRoute, FromDishka -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, status from src.api.dependencies import get_current_admin_user from src.api.schemas.repository import (