diff --git a/backend/services/project.py b/backend/services/project.py index 1b88b74..b06229e 100644 --- a/backend/services/project.py +++ b/backend/services/project.py @@ -1,5 +1,5 @@ from types import TracebackType -from typing import Self, cast +from typing import Self from core.config.application import settings from core.constants import ProjectVisibility @@ -9,7 +9,7 @@ from core.exceptions.user import UserIdNotFoundError from core.interfaces.clients import AbstractS3Client, AbstractUnitOfWorkClient from core.logging import get_logger -from infrastructure.database.models import Project, User +from infrastructure.database.models import Project from infrastructure.database.repositories import ( FileRepository, OutboxRepository, @@ -164,6 +164,9 @@ async def handle_render_generated( key = file_location.key project = await self.project_repository.get_by_id(project_id) + if project is None: + raise ProjectIdNotFoundError(project_id) + name = f"{project.name}.png" size = await s3_client.get_file_size(bucket, key) file = FileCreate( @@ -338,6 +341,8 @@ async def _validate_project_owner(self, project_id: int, user_id: int) -> None: get_owner_coroutine = self.project_repository.get_project_owner( project_id, ) - owner = cast(User, await get_owner_coroutine) - if owner.id != user_id: + project_owner = await get_owner_coroutine + if project_owner is None: + raise UserIdNotFoundError(user_id) + if project_owner.id != user_id: raise PermissionDeniedError diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index bb14b18..762912b 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -6,6 +6,7 @@ from infrastructure.minio.client import MinioClient from services.auth import AuthService from services.file_uploader import FileUploader +from services.project import ProjectService from services.tag import TagService from services.user import UserService from sqlalchemy.ext.asyncio import ( @@ -108,6 +109,14 @@ async def file_uploader( return file_uploader +@pytest.fixture(scope="function") +def project_service( + unit_of_work: UnitOfWork, +) -> ProjectService: + project_service = ProjectService(unit_of_work) + return project_service + + @pytest.fixture(scope="function") def tag_service( unit_of_work: UnitOfWork, diff --git a/backend/tests/test_services/conftest.py b/backend/tests/test_services/conftest.py index 5c5babc..b99cd0d 100644 --- a/backend/tests/test_services/conftest.py +++ b/backend/tests/test_services/conftest.py @@ -1,6 +1,6 @@ import pytest -from core.constants import ProjectVisibility -from infrastructure.database.models import Project, Tag, User +from core.constants import ProjectVisibility, RenderStatus +from infrastructure.database.models import File, Project, Render, Tag, User from schemas.tag import TagCreate @@ -21,16 +21,66 @@ def project_owner() -> User: def project(project_owner: User) -> Project: project = Project( id=1, - name="name", + name="project_name", description="", user_id=project_owner.id, source_file_id=1, render_id=1, + status=RenderStatus.rendering, visibility=ProjectVisibility.public, ) return project +@pytest.fixture +def source_file() -> File: + file = File( + id=2, + name="source_file_name", + size=1000, + bucket="source_bucket_name", + key="source_key_name", + ) + return file + + +@pytest.fixture +def render_file() -> File: + file = File( + id=1, + name="file_name", + size=1000, + bucket="bucket_name", + key="key_name", + ) + return file + + +@pytest.fixture +def render(render_file: File) -> Render: + render = Render( + id=1, + width=1000, + height=1000, + samples=100, + denoiser=True, + gpu=True, + file_id=render_file.id, + ) + render.file = render_file + return render + + +@pytest.fixture +def full_project(project: Project, render: Render, source_file: File) -> Project: + project.render_id = render.id + project.render = render + + project.source_file_id = source_file.id + project.source_file = source_file + return project + + @pytest.fixture def create_tag_data(project: Project) -> TagCreate: return TagCreate( diff --git a/backend/tests/test_services/test_project/__init__.py b/backend/tests/test_services/test_project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/test_services/test_project/conftest.py b/backend/tests/test_services/test_project/conftest.py new file mode 100644 index 0000000..43cb5c5 --- /dev/null +++ b/backend/tests/test_services/test_project/conftest.py @@ -0,0 +1,90 @@ +import pytest +from core.constants import ProjectVisibility +from infrastructure.database.models import Project, Render +from schemas.event import RenderGeneratedEvent +from schemas.file import FileLocationCreate +from schemas.project import ProjectBase, ProjectCreate, ProjectWithRenderCreate +from schemas.render import RenderBase, RenderCreatePayload, SunInfo + + +@pytest.fixture(scope="function") +def output(render_file: Render) -> FileLocationCreate: + output = FileLocationCreate( + bucket=render_file.bucket, + key=render_file.key, + ) + return output + + +@pytest.fixture(scope="function") +def render_generated_event( + full_project: Project, + output: FileLocationCreate, +) -> RenderGeneratedEvent: + render_generated_event = RenderGeneratedEvent( + project_id=full_project.id, + output=output, + ) + return render_generated_event + + +@pytest.fixture(scope="function") +def render_base_data() -> RenderBase: + return RenderBase( + width=1000, + height=1000, + samples=100, + denoiser=True, + gpu=True, + ) + + +@pytest.fixture(scope="function") +def sun() -> SunInfo: + return SunInfo( + direction=[1.0, 1.0, 1.0], + color=[1.0, 1.0, 1.0], + exponent=100, + ) + + +@pytest.fixture(scope="function") +def render_create_data( + render_base_data: RenderBase, + sun: SunInfo, +) -> RenderCreatePayload: + return RenderCreatePayload( + background=[1.0, 1.0, 1.0], + sun=sun, + **render_base_data.model_dump(), + ) + + +@pytest.fixture(scope="function") +def project_base_data() -> ProjectBase: + return ProjectBase( + name="project_name", + description="", + source_file_id=1, + ) + + +@pytest.fixture(scope="function") +def project_create_data( + project_base_data: ProjectBase, +) -> ProjectCreate: + return ProjectCreate( + visibility=ProjectVisibility.public, + **project_base_data.model_dump(), + ) + + +@pytest.fixture(scope="function") +def create_project_request( + render_create_data: RenderCreatePayload, + project_create_data: ProjectCreate, +) -> ProjectWithRenderCreate: + return ProjectWithRenderCreate( + render=render_create_data, + project=project_create_data, + ) diff --git a/backend/tests/test_services/test_project/test_project_pagination.py b/backend/tests/test_services/test_project/test_project_pagination.py new file mode 100644 index 0000000..bdfc971 --- /dev/null +++ b/backend/tests/test_services/test_project/test_project_pagination.py @@ -0,0 +1,102 @@ +import pytest +from core.exceptions.user import UserIdNotFoundError +from infrastructure.database.models import Project, User +from pytest_mock import MockerFixture +from services.project import ProjectService + + +class TestProjectPaginationService: + async def test_get_user_projects_valid( + self, + project_owner: User, + full_project: Project, + mocker: MockerFixture, + project_service: ProjectService, + ) -> None: + mocker.patch.object( + project_service.user_repository, + "get_by_id", + return_value=project_owner, + ) + mocker.patch.object( + project_service.project_repository, + "get_user_projects", + return_value=[full_project], + autospec=True, + ) + await project_service.get_user_projects( + project_owner.id, + size=10, + page=1, + ) + + async def test_get_user_projects_user_not_exists( + self, + project_owner: User, + mocker: MockerFixture, + project_service: ProjectService, + ) -> None: + mocker.patch.object( + project_service.user_repository, + "get_by_id", + return_value=None, + autospec=True, + ) + with pytest.raises(UserIdNotFoundError): + await project_service.get_user_projects( + project_owner.id, + size=10, + page=1, + ) + + async def test_get_public_projects_valid( + self, + full_project: Project, + mocker: MockerFixture, + project_service: ProjectService, + ) -> None: + mocker.patch.object( + project_service.project_repository, + "get_public_projects", + return_value=[full_project], + autospec=True, + ) + await project_service.get_public_projects( + size=10, + page=1, + ) + + async def test_get_user_public_projects_valid( + self, + project_owner: User, + mocker: MockerFixture, + project_service: ProjectService, + ) -> None: + mocker.patch.object( + project_service.user_repository, + "get_by_id", + return_value=project_owner, + ) + await project_service.get_user_public_projects( + user_id=project_owner.id, + size=10, + page=1, + ) + + async def test_get_user_public_projects_user_not_exists( + self, + project_owner: User, + mocker: MockerFixture, + project_service: ProjectService, + ) -> None: + mocker.patch.object( + project_service.user_repository, + "get_by_id", + return_value=None, + ) + with pytest.raises(UserIdNotFoundError): + await project_service.get_user_public_projects( + user_id=project_owner.id, + size=10, + page=1, + ) diff --git a/backend/tests/test_services/test_project/test_project_service.py b/backend/tests/test_services/test_project/test_project_service.py new file mode 100644 index 0000000..ab6b70e --- /dev/null +++ b/backend/tests/test_services/test_project/test_project_service.py @@ -0,0 +1,283 @@ +import pytest +from core.constants import ProjectVisibility +from core.exceptions.auth import PermissionDeniedError +from core.exceptions.file import FileIdNotFoundError +from core.exceptions.project import ProjectIdNotFoundError +from infrastructure.database.models import File, Project, Render, User +from infrastructure.minio.client import MinioClient +from pytest_mock import MockerFixture +from schemas.project import ( + ProjectPartialUpdate, + ProjectWithRenderCreate, +) +from services.project import ProjectService + + +class TestProjectService: + async def test_get_by_id_valid( + self, + project_owner: User, + full_project: Project, + mocker: MockerFixture, + minio_client: MinioClient, + project_service: ProjectService, + ) -> None: + mocker.patch.object( + project_service, + "_validate_access_to_get_project", + return_value=full_project, + autospec=True, + ) + mocker.patch( + "services.project.ProjectWithRenderFileFullResponse.get_from_database", + return_value=full_project, + autospec=True, + ) + + await project_service.get_by_id( + full_project.id, + project_owner.id, + minio_client, + ) + + async def test_get_by_id_project_not_exists( + self, + project_owner: User, + full_project: Project, + mocker: MockerFixture, + minio_client: MinioClient, + project_service: ProjectService, + ) -> None: + mocker.patch.object( + project_service.project_repository, + "get_by_id", + return_value=None, + autospec=True, + ) + with pytest.raises(ProjectIdNotFoundError): + await project_service.get_by_id( + full_project.id, + project_owner.id, + minio_client, + ) + + async def test_get_private_project_by_id_not_by_owner( + self, + project_owner: User, + full_project: Project, + mocker: MockerFixture, + minio_client: MinioClient, + project_service: ProjectService, + ) -> None: + full_project.visibility = ProjectVisibility.private + mocker.patch.object( + project_service.project_repository, + "get_by_id", + return_value=full_project, + autospec=True, + ) + mocker.patch.object( + project_service.project_repository, + "get_project_owner", + return_value=project_owner, + autospec=True, + ) + with pytest.raises(PermissionDeniedError): + await project_service.get_by_id( + full_project.id, + project_owner.id + 1, + minio_client, + ) + + async def test_create_project_valid( + self, + create_project_request: ProjectWithRenderCreate, + render: Render, + source_file: File, + project_owner: User, + full_project: Project, + mocker: MockerFixture, + project_service: ProjectService, + ) -> None: + mocker.patch.object( + project_service.file_repository, + "get_by_id", + return_value=source_file, + autospec=True, + ) + mocker.patch.object( + project_service.render_repository, + "create_render", + return_value=render, + autospec=True, + ) + mocker.patch.object( + project_service.project_repository, + "create_project", + return_value=full_project, + autospec=True, + ) + mocker.patch.object( + project_service.outbox_repository, + "create_event", + return_value=None, + autospec=True, + ) + + await project_service.create_project( + user_id=project_owner.id, + create_project=create_project_request, + ) + + async def test_create_project_file_not_exists( + self, + create_project_request: ProjectWithRenderCreate, + project_owner: User, + mocker: MockerFixture, + project_service: ProjectService, + ) -> None: + mocker.patch.object( + project_service.file_repository, + "get_by_id", + return_value=None, + autospec=True, + ) + with pytest.raises(FileIdNotFoundError): + await project_service.create_project( + user_id=project_owner.id, + create_project=create_project_request, + ) + + async def test_partial_update_project_valid( + self, + project_owner: User, + full_project: Project, + mocker: MockerFixture, + project_service: ProjectService, + ) -> None: + mocker.patch.object( + project_service.project_repository, + "get_by_id", + return_value=full_project, + autospec=True, + ) + mocker.patch.object( + project_service.project_repository, + "get_project_owner", + return_value=project_owner, + autospec=True, + ) + partial_update_project_data = ProjectPartialUpdate( + visibility=ProjectVisibility.private, + ) + await project_service.partial_update_project( + full_project.id, + project_owner.id, + partial_update_project_data, + ) + + async def test_partial_update_project_not_exists( + self, + project_owner: User, + full_project: Project, + mocker: MockerFixture, + project_service: ProjectService, + ) -> None: + mocker.patch.object( + project_service.project_repository, + "get_by_id", + return_value=None, + autospec=True, + ) + partial_update_project_data = ProjectPartialUpdate( + visibility=ProjectVisibility.private, + ) + with pytest.raises(ProjectIdNotFoundError): + await project_service.partial_update_project( + full_project.id, + project_owner.id, + partial_update_project_data, + ) + + async def test_partial_update_project_not_by_owner( + self, + project_owner: User, + full_project: Project, + mocker: MockerFixture, + project_service: ProjectService, + ) -> None: + mocker.patch.object( + project_service.project_repository, + "get_by_id", + return_value=full_project, + autospec=True, + ) + mocker.patch.object( + project_service.project_repository, + "get_project_owner", + return_value=project_owner, + autospec=True, + ) + partial_update_project_data = ProjectPartialUpdate( + description="new description", + ) + with pytest.raises(PermissionDeniedError): + await project_service.partial_update_project( + full_project.id, + project_owner.id + 1, + partial_update_project_data, + ) + + async def test_delete_by_id_valid( + self, + project_owner: User, + full_project: Project, + mocker: MockerFixture, + project_service: ProjectService, + ) -> None: + mocker.patch.object( + project_service.project_repository, + "get_by_id", + return_value=full_project, + ) + mocker.patch.object( + project_service.project_repository, + "get_project_owner", + return_value=project_owner, + ) + await project_service.delete_by_id(full_project.id, project_owner.id) + + async def test_delete_by_id_project_not_exists( + self, + project_owner: User, + full_project: Project, + mocker: MockerFixture, + project_service: ProjectService, + ) -> None: + mocker.patch.object( + project_service.project_repository, + "get_by_id", + return_value=None, + ) + with pytest.raises(ProjectIdNotFoundError): + await project_service.delete_by_id(full_project.id, project_owner.id) + + async def test_delete_by_id_not_by_owner( + self, + project_owner: User, + full_project: Project, + mocker: MockerFixture, + project_service: ProjectService, + ) -> None: + mocker.patch.object( + project_service.project_repository, + "get_by_id", + return_value=None, + ) + mocker.patch.object( + project_service.project_repository, + "get_project_owner", + return_value=project_owner, + ) + with pytest.raises(ProjectIdNotFoundError): + await project_service.delete_by_id(full_project.id, project_owner.id + 1) diff --git a/backend/tests/test_services/test_project/test_render_generate_handler.py b/backend/tests/test_services/test_project/test_render_generate_handler.py new file mode 100644 index 0000000..01445f5 --- /dev/null +++ b/backend/tests/test_services/test_project/test_render_generate_handler.py @@ -0,0 +1,88 @@ +import pytest +from core.exceptions.project import ProjectIdNotFoundError +from infrastructure.database.models import File, Project +from infrastructure.minio.client import MinioClient +from pytest_mock import MockerFixture +from schemas.event import RenderGeneratedEvent +from services.project import ProjectService + + +class TestRenderGenerateHandler: + async def test_handle_render_generated_valid( + self, + render_file: File, + full_project: Project, + render_generated_event: RenderGeneratedEvent, + mocker: MockerFixture, + minio_client: MinioClient, + project_service: ProjectService, + ) -> None: + mocker.patch.object( + project_service.project_repository, + "get_by_id", + return_value=full_project, + ) + mocker.patch.object( + minio_client, + "get_file_size", + return_value=1000, + ) + mocker.patch.object( + project_service.file_repository, + "create_file", + return_value=render_file, + ) + mocker.patch.object( + project_service.render_repository, + "add_render_file", + return_value=None, + ) + await project_service.handle_render_generated( + render_generated_event, + minio_client, + ) + + async def test_handle_render_generated_project_not_exists( + self, + render_generated_event: RenderGeneratedEvent, + mocker: MockerFixture, + minio_client: MinioClient, + project_service: ProjectService, + ) -> None: + mocker.patch.object( + project_service.project_repository, + "get_by_id", + return_value=None, + ) + with pytest.raises(ProjectIdNotFoundError): + await project_service.handle_render_generated( + render_generated_event, + minio_client, + ) + + async def test_update_project_status_valid( + self, + full_project: Project, + mocker: MockerFixture, + project_service: ProjectService, + ) -> None: + mocker.patch.object( + project_service.project_repository, + "update_project_status", + return_value=full_project, + ) + await project_service.update_project_status(full_project.id) + + async def test_update_not_exists_project( + self, + full_project: Project, + mocker: MockerFixture, + project_service: ProjectService, + ) -> None: + mocker.patch.object( + project_service.project_repository, + "update_project_status", + return_value=None, + ) + with pytest.raises(ProjectIdNotFoundError): + await project_service.update_project_status(full_project.id)