diff --git a/pyproject.toml b/pyproject.toml index 0d6a8cd..5350f16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -184,3 +184,27 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/template_project"] + + +[tool.coverage.report] +show_missing = true +skip_empty = true +exclude_also = [ + "if __name__ == .__main__.:", + "self.logger", + "def __repr__", + "lambda: None", + "from .*", + "import .*", + '@(abc\.)?abstractmethod', + "raise NotImplementedError", + 'raise AssertionError', + 'logger\..*', + "pass", + '\.\.\.', +] +omit = [ + '*/__about__.py', + '*/__main__.py', + '*/__init__.py', +] diff --git a/src/template_project/application/resume/interactors/edit.py b/src/template_project/application/resume/interactors/edit.py new file mode 100644 index 0000000..2305bd5 --- /dev/null +++ b/src/template_project/application/resume/interactors/edit.py @@ -0,0 +1,56 @@ +from template_project.application.common.data_structure import to_data_structure +from template_project.application.common.enums import ExperienceType +from template_project.application.common.identity_provider import IdentityProvider +from template_project.application.common.interactor import to_interactor +from template_project.application.common.unit_of_work import UnitOfWork +from template_project.application.resume.data_gateway import ResumeDataGateway +from template_project.application.resume.entity import ResumeId +from template_project.application.resume.errors import ResumeDoesBelongUserError + + +@to_data_structure +class _Response: + id: ResumeId + position: str + about_me: str + key_skills: list[str] + experience_type: ExperienceType + + +@to_interactor +class EditResumeInteractor: + unit_of_work: UnitOfWork + identity_provider: IdentityProvider + resume_data_gateway: ResumeDataGateway + + async def execute( + self, + resume_id: ResumeId, + position: str | None, + about_me: str | None, + key_skills: list[str] | None, + experience_type: ExperienceType | None, + ) -> _Response: + user = await self.identity_provider.get_current_user() + resume = await self.resume_data_gateway.load(resume_id) + if resume.user_id != user.id: + raise ResumeDoesBelongUserError + + if position is not None: + resume.position = position + if about_me is not None: + resume.about_me = about_me + if key_skills is not None: + resume.key_skills = key_skills + if experience_type is not None: + resume.experience_type = experience_type + + await self.unit_of_work.commit() + + return _Response( + id=resume.id, + position=resume.position, + about_me=resume.about_me, + key_skills=resume.key_skills, + experience_type=resume.experience_type, + ) diff --git a/src/template_project/application/resume/interactors/get.py b/src/template_project/application/resume/interactors/get.py index b272c15..613551f 100644 --- a/src/template_project/application/resume/interactors/get.py +++ b/src/template_project/application/resume/interactors/get.py @@ -18,6 +18,7 @@ class ResumePredictionResponse: @to_data_structure class _Response: + id: ResumeId position: str about_me: str key_skills: list[str] @@ -53,6 +54,7 @@ class GetResumeInteractor: prediction = None return _Response( + id=resume.id, position=resume.position, about_me=resume.about_me, key_skills=resume.key_skills, diff --git a/src/template_project/web_api/ioc/interactor.py b/src/template_project/web_api/ioc/interactor.py index 17d97fe..1f75681 100644 --- a/src/template_project/web_api/ioc/interactor.py +++ b/src/template_project/web_api/ioc/interactor.py @@ -7,6 +7,7 @@ from template_project.application.notification_device.interactors.register_devic ) from template_project.application.notification_device.interactors.send_notification import NotificationInteractor from template_project.application.resume.interactors.add import AddResumeInteractor +from template_project.application.resume.interactors.edit import EditResumeInteractor from template_project.application.resume.interactors.get import GetResumeInteractor from template_project.application.user.profile.interactors.get_profile import GetProfileInteractor from template_project.application.user.profile.interactors.patch_profile import PatchProfileInteractor @@ -24,4 +25,5 @@ class InteractorProvider(Provider): RegisterNotificationDeviceInteractor, GetResumeInteractor, AddResumeInteractor, + EditResumeInteractor, ) diff --git a/src/template_project/web_api/routes/resume.py b/src/template_project/web_api/routes/resume.py index 78dae13..a5f7407 100644 --- a/src/template_project/web_api/routes/resume.py +++ b/src/template_project/web_api/routes/resume.py @@ -12,6 +12,7 @@ from template_project.application.common.enums import ExperienceType from template_project.application.resume.entity import ResumeId from template_project.application.resume.errors import ResumeDoesBelongUserError, ResumeNotFoundError from template_project.application.resume.interactors.add import AddResumeInteractor +from template_project.application.resume.interactors.edit import EditResumeInteractor from template_project.application.resume.interactors.get import GetResumeInteractor security = HTTPBearer() @@ -66,6 +67,7 @@ class SalaryPrediction(BaseModel): class ResumeResponse(BaseModel): + id: ResumeId = Field(description="Resume ID") position: str = Field(description="Job position") about_me: str = Field(description="About me section") key_skills: list[str] = Field(description="List of key skills") @@ -90,6 +92,7 @@ class ResumeResponse(BaseModel): class ResumeListItem(BaseModel): + id: ResumeId = Field(description="Resume ID") position: str = Field(description="Job position") about_me: str = Field(description="About me section") key_skills: list[str] = Field(description="List of key skills") @@ -208,6 +211,7 @@ async def get_resume( ) from error return ResumeResponse( + id=interactor_response.id, position=interactor_response.position, about_me=interactor_response.about_me, key_skills=interactor_response.key_skills, @@ -266,6 +270,7 @@ class PatchResumeRequest(BaseModel): class PatchResumeResponse(BaseModel): + id: ResumeId = Field(description="Resume ID") position: str = Field(description="Job position") about_me: str = Field(description="About me section") key_skills: list[str] = Field(description="List of key skills") @@ -310,11 +315,37 @@ async def get_resume_history( 200: {"description": "Resume updated successfully", "model": PatchResumeResponse}, 401: {"description": "Unauthorized - invalid or missing token"}, 404: {"description": "Resume not found"}, + 403: {"descriptipn": "The resume does not belong to you"}, }, ) async def patch_resume( - resume_id: str, + resume_id: ResumeId, request: PatchResumeRequest, + interactor: FromDishka[EditResumeInteractor], ) -> PatchResumeResponse: - # TODO: Implement resume update - raise NotImplementedError + try: + interactor_response = await interactor.execute( + resume_id=resume_id, + position=request.position, + about_me=request.about_me, + key_skills=request.key_skills, + experience_type=request.experience_type, + ) + except ResumeDoesBelongUserError as error: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="The resume does not belong to you", + ) from error + except ResumeNotFoundError as error: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Resume not found", + ) from error + + return PatchResumeResponse( + id=interactor_response.id, + position=interactor_response.position, + about_me=interactor_response.about_me, + key_skills=interactor_response.key_skills, + experience_type=interactor_response.experience_type, + ) diff --git a/tests/web_api/conftest.py b/tests/web_api/conftest.py index f3fcda1..5e40161 100644 --- a/tests/web_api/conftest.py +++ b/tests/web_api/conftest.py @@ -1,11 +1,13 @@ import asyncio +import logging import os from collections.abc import AsyncIterable, AsyncIterator from pathlib import Path -from typing import Any +from typing import cast import pytest from dishka import AsyncContainer +from fastapi import FastAPI from template_project.web_api.configuration import load_configuration from template_project.web_api.entry_point import make_server @@ -14,20 +16,23 @@ from tests.web_api.ioc import make_ioc @pytest.fixture(scope="session") -async def dishka_container(backend: Any) -> AsyncIterable[AsyncContainer]: +async def dishka_container(backend: FastAPI) -> AsyncIterable[AsyncContainer]: path = Path(os.environ["CONFIGURATION_PATH"]) configuration = load_configuration(path) - ioc = make_ioc(configuration) + ioc = make_ioc(configuration, backend) yield ioc await ioc.close() @pytest.fixture(scope="session") -async def backend() -> AsyncIterator[None]: +async def backend() -> AsyncIterator[FastAPI]: + logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("httpcore").setLevel(logging.WARNING) + configuration = load_configuration(Path(os.environ["CONFIGURATION_PATH"])) server = make_server(configuration) asyncio.create_task(server.serve()) # type: ignore[unused-awaitable] - yield + yield cast(FastAPI, server.config.app) @pytest.fixture diff --git a/tests/web_api/e2e/test_resume.py b/tests/web_api/e2e/test_resume.py index b29cf73..5e321b4 100644 --- a/tests/web_api/e2e/test_resume.py +++ b/tests/web_api/e2e/test_resume.py @@ -1,10 +1,15 @@ from typing import Final -from dirty_equals import IsDict, IsUUID +from dirty_equals import IsDict, IsPartialDict, IsUUID from dishka import FromDishka from uuid_utils.compat import uuid7 -from tests.web_api.helpers import is_not_found_response, is_success_response, is_unauthorized_response +from tests.web_api.helpers import ( + is_forbidden_response, + is_not_found_response, + is_success_response, + is_unauthorized_response, +) from tests.web_api.ioc import DatabaseClearer, inject from tests.web_api.test_api_gateway import TestApiGateway @@ -129,3 +134,123 @@ async def test_not_found_get_resume( resume_id=str(uuid7()), ) assert is_not_found_response(response) + + +@inject +async def test_success_edit_resume( + unique_email: str, + test_api_gateway: FromDishka[TestApiGateway], + database_clearer: FromDishka[DatabaseClearer], +) -> None: + await database_clearer.clear() + + response_sign_up = await test_api_gateway.sign_up_email(unique_email, DEFAULT_PASSWORD) + access_token = response_sign_up.json()["access_token"] + + response = await test_api_gateway.create_resume( + access_token=access_token, + about_me="About me", + experience_type="noExperience", + key_skills=["i love lisp", "i love rust"], + position="Position", + ) + resume_id = response.json()["resume_id"] + response = await test_api_gateway.edit_resume( + access_token=access_token, + resume_id=resume_id, + about_me="Updated about me", + experience_type="between1And3", + key_skills=["i love python"], + position="Updated Position", + ) + assert is_success_response(response) + assert response.json() == IsPartialDict( + position="Updated Position", + about_me="Updated about me", + key_skills=["i love python"], + experience_type="between1And3", + ) + + +@inject +async def test_unauthorized_edit_resume( + unique_email: str, + test_api_gateway: FromDishka[TestApiGateway], + database_clearer: FromDishka[DatabaseClearer], +) -> None: + await database_clearer.clear() + + response_sign_up = await test_api_gateway.sign_up_email(unique_email, DEFAULT_PASSWORD) + access_token = response_sign_up.json()["access_token"] + + response = await test_api_gateway.create_resume( + access_token=access_token, + about_me="About me", + experience_type="noExperience", + key_skills=["i love lisp", "i love rust"], + position="Position", + ) + resume_id = response.json()["resume_id"] + response = await test_api_gateway.edit_resume( + access_token="...", + resume_id=resume_id, + about_me="Updated about me", + experience_type="between1And3", + key_skills=["i love python"], + position="Updated Position", + ) + assert is_unauthorized_response(response) + + +@inject +async def test_not_found_edit_resume( + unique_email: str, + test_api_gateway: FromDishka[TestApiGateway], + database_clearer: FromDishka[DatabaseClearer], +) -> None: + await database_clearer.clear() + + response_sign_up = await test_api_gateway.sign_up_email(unique_email, DEFAULT_PASSWORD) + access_token = response_sign_up.json()["access_token"] + + response = await test_api_gateway.edit_resume( + access_token=access_token, + resume_id=str(uuid7()), + about_me="Updated about me", + experience_type="between1And3", + key_skills=["i love python"], + position="Updated Position", + ) + assert is_not_found_response(response) + + +@inject +async def test_forbidden_edit_resume( + unique_email: str, + test_api_gateway: FromDishka[TestApiGateway], + database_clearer: FromDishka[DatabaseClearer], +) -> None: + await database_clearer.clear() + + response_sign_up = await test_api_gateway.sign_up_email(unique_email, DEFAULT_PASSWORD) + + response = await test_api_gateway.create_resume( + access_token=response_sign_up.json()["access_token"], + about_me="About me", + experience_type="noExperience", + key_skills=["i love lisp", "i love rust"], + position="Position", + ) + + response_other_sign_up = await test_api_gateway.sign_up_email("f" + unique_email, DEFAULT_PASSWORD) + + response = await test_api_gateway.edit_resume( + access_token=response_other_sign_up.json()["access_token"], + resume_id=response.json()["resume_id"], + about_me="Updated about me", + experience_type="between1And3", + key_skills=["i love python"], + position="Updated Position", + ) + + assert is_forbidden_response(response) diff --git a/tests/web_api/ioc.py b/tests/web_api/ioc.py index 2f7f440..1885969 100644 --- a/tests/web_api/ioc.py +++ b/tests/web_api/ioc.py @@ -1,4 +1,4 @@ -from collections.abc import Callable +from collections.abc import AsyncIterable, Callable from inspect import Parameter from typing import Final @@ -13,7 +13,8 @@ from dishka import ( ) from dishka.integrations.base import wrap_injection from dishka.integrations.fastapi import FastapiProvider -from httpx import AsyncClient +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession @@ -71,11 +72,13 @@ class TestProvider(Provider): database_clearer = provide(DatabaseClearer) @provide - def http_client(self) -> AsyncClient: - return AsyncClient(base_url="http://backend:8080") + async def http_client(self, app: FastAPI) -> AsyncIterable[AsyncClient]: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as client: + yield client -def make_ioc(configuration: Configuration) -> AsyncContainer: +def make_ioc(configuration: Configuration, app: FastAPI) -> AsyncContainer: return make_async_container( IdPProvider(), FactoryProvider(), @@ -97,6 +100,7 @@ def make_ioc(configuration: Configuration) -> AsyncContainer: FirebaseConfiguration: configuration.firebase, Configuration: configuration, S3Config: configuration.s3, + FastAPI: app, }, ) diff --git a/tests/web_api/test_api_gateway.py b/tests/web_api/test_api_gateway.py index 5a494d6..b24f81c 100644 --- a/tests/web_api/test_api_gateway.py +++ b/tests/web_api/test_api_gateway.py @@ -78,3 +78,27 @@ class TestApiGateway: f"/resume/{resume_id}", headers=make_auth_headers(access_token), ) + + async def edit_resume( + self, + access_token: str, + resume_id: str, + position: str | None = None, + about_me: str | None = None, + key_skills: list[str] | None = None, + experience_type: str | None = None, + ) -> Response: + return await self._client.patch( + f"/resume/{resume_id}", + json={ + key: value + for key, value in { + "position": position, + "about_me": about_me, + "key_skills": key_skills, + "experience_type": experience_type, + }.items() + if value is not None + }, + headers=make_auth_headers(access_token), + )