From edfc74d5c6e9c87fac611ceabd511febefd3dee8 Mon Sep 17 00:00:00 2001 From: ZhakovArtyom <127793560+zhakovartyom@users.noreply.github.com> Date: Wed, 2 Apr 2025 02:05:31 +0500 Subject: [PATCH] feat: upload resume pdf file --- app/common/bridges/public_users_bdg.py | 10 +- app/common/schemas/demo_form_sch.py | 8 + app/common/schemas/vacancy_form_sch.py | 3 +- app/supbot/routers/vacancy_tgm.py | 41 ++++-- app/supbot/texts.py | 7 +- app/supbot/utils/filters.py | 52 +++++++ app/users/dependencies/forms_dep.py | 21 +++ app/users/routes/forms_rst.py | 51 ++----- poetry.lock | 31 ++-- pyproject.toml | 2 +- tests/conftest.py | 24 +++ tests/supbot/factories.py | 6 +- tests/supbot/test_vacancy.py | 193 ++++++++++++++++++++++--- tests/users/conftest.py | 11 -- tests/users/test_forms.py | 54 +------ 15 files changed, 363 insertions(+), 151 deletions(-) create mode 100644 app/common/schemas/demo_form_sch.py create mode 100644 app/users/dependencies/forms_dep.py diff --git a/app/common/bridges/public_users_bdg.py b/app/common/bridges/public_users_bdg.py index a7b8545..74ce2f9 100644 --- a/app/common/bridges/public_users_bdg.py +++ b/app/common/bridges/public_users_bdg.py @@ -1,3 +1,5 @@ +from typing import BinaryIO + from httpx import AsyncClient from app.common.config import settings @@ -10,8 +12,12 @@ def __init__(self) -> None: base_url=settings.bridge_base_url, ) - async def apply_for_vacancy(self, vacancy_form: VacancyFormSchema) -> None: + async def apply_for_vacancy( + self, vacancy_form: VacancyFormSchema, resume: tuple[str, BinaryIO, str] + ) -> None: response = await self.client.post( - "/api/vacancy-applications/", json=vacancy_form.model_dump() + "/api/v2/vacancy-applications/", + data=vacancy_form.model_dump(), + files={"resume": resume}, ) response.raise_for_status() diff --git a/app/common/schemas/demo_form_sch.py b/app/common/schemas/demo_form_sch.py new file mode 100644 index 0000000..697693a --- /dev/null +++ b/app/common/schemas/demo_form_sch.py @@ -0,0 +1,8 @@ +from typing import Annotated + +from pydantic import BaseModel, Field + + +class DemoFormSchema(BaseModel): + name: str + contacts: Annotated[list[str], Field(min_length=1)] diff --git a/app/common/schemas/vacancy_form_sch.py b/app/common/schemas/vacancy_form_sch.py index 2afab3e..3d0ff50 100644 --- a/app/common/schemas/vacancy_form_sch.py +++ b/app/common/schemas/vacancy_form_sch.py @@ -2,8 +2,7 @@ class VacancyFormSchema(BaseModel): + position: str name: str telegram: str - position: str - link: str message: str | None = None diff --git a/app/supbot/routers/vacancy_tgm.py b/app/supbot/routers/vacancy_tgm.py index 7a05769..6dead13 100644 --- a/app/supbot/routers/vacancy_tgm.py +++ b/app/supbot/routers/vacancy_tgm.py @@ -1,3 +1,5 @@ +from typing import BinaryIO + from aiogram import F, Router from aiogram.filters import StateFilter from aiogram.fsm.context import FSMContext @@ -8,7 +10,7 @@ from app.common.schemas.vacancy_form_sch import VacancyFormSchema from app.supbot import texts from app.supbot.utils.aiogram_ext import MessageExt, MessageFromUser -from app.supbot.utils.filters import command_filter +from app.supbot.utils.filters import DocumentErrorType, DocumentFilter, command_filter router = Router() @@ -118,13 +120,11 @@ async def set_telegram_and_request_resume( ) -@router.message(VacancyStates.sending_resume, F.text) -@router.message(VacancyStates.sending_comment, F.text == texts.BACK_BUTTON_TEXT) +@router.message(VacancyStates.sending_resume, DocumentFilter()) async def set_resume_and_request_comment( - message: MessageExt, state: FSMContext + message: MessageExt, document_data: tuple[str, BinaryIO, str], state: FSMContext ) -> None: - if message.text != texts.BACK_BUTTON_TEXT: - await state.update_data(link=message.text) + await state.update_data(resume=document_data) await state.set_state(VacancyStates.sending_comment) await message.answer( text=texts.SEND_INFO_MESSAGE, @@ -142,13 +142,13 @@ async def submit_vacancy_form(message: MessageExt, state: FSMContext) -> None: answers["message"] = message.text await public_users_bridge.apply_for_vacancy( - VacancyFormSchema( - name=answers["name"], + vacancy_form=VacancyFormSchema( position=answers["position"], + name=answers["name"], telegram=answers["telegram"], - link=answers["link"], message=answers.get("message"), - ) + ), + resume=answers["resume"], ) await message.answer( @@ -160,6 +160,27 @@ async def submit_vacancy_form(message: MessageExt, state: FSMContext) -> None: await state.clear() +@router.message( + VacancyStates.sending_resume, DocumentFilter(DocumentErrorType.NO_DOCUMENT) +) +async def handle_missing_document(message: MessageExt) -> None: + await message.answer(texts.VACANCY_NO_DOCUMENT_MESSAGE) + + +@router.message( + VacancyStates.sending_resume, DocumentFilter(DocumentErrorType.WRONG_MIME_TYPE) +) +async def handle_unsupported_document_type(message: MessageExt) -> None: + await message.answer(texts.VACANCY_UNSUPPORTED_DOCUMENT_TYPE_MESSAGE) + + +@router.message( + VacancyStates.sending_resume, DocumentFilter(DocumentErrorType.FILE_TO_LARGE) +) +async def handle_unsupported_document_size(message: MessageExt) -> None: + await message.answer(texts.VACANCY_DOCUMENT_TOO_LARGE_MESSAGE) + + @router.message( StateFilter(*VacancyStates.__all_states__), ~F.text, diff --git a/app/supbot/texts.py b/app/supbot/texts.py index 3eb77a9..faf17e8 100644 --- a/app/supbot/texts.py +++ b/app/supbot/texts.py @@ -61,9 +61,14 @@ CHOOSE_VACANCY_MESSAGE = "Выберите вакансию или введите свою:" SEND_NAME_MESSAGE = "Как вас зовут?" SEND_TELEGRAM_MESSAGE = "Пожалуйста, оставьте ваш телеграм ⬇️" -SEND_RESUME_MESSAGE = "Добавьте ссылку на резюме ⬇️" +SEND_RESUME_MESSAGE = ( + "Пожалуйста, загрузите ваше резюме одним файлом в формате PDF (до 10 MiB) ⬇️" +) SEND_INFO_MESSAGE = "Почти готово. Можете оставить для нас сообщение 🙂" VACANCY_FORM_FINAL_MESSAGE = "Спасибо! Мы получили ваш отклик и рассмотрим его" +VACANCY_NO_DOCUMENT_MESSAGE = f"В сообщении не прикреплён файл. {SEND_RESUME_MESSAGE}" +VACANCY_UNSUPPORTED_DOCUMENT_TYPE_MESSAGE = f"Неверный тип файла. {SEND_RESUME_MESSAGE}" +VACANCY_DOCUMENT_TOO_LARGE_MESSAGE = f"Слишком большой файл. {SEND_RESUME_MESSAGE}" VACANCY_INVALID_INPUT_TYPE_MESSAGE = """ Пожалуйста, используйте только текстовые сообщения или кнопки для заполнения формы вакансии """ diff --git a/app/supbot/utils/filters.py b/app/supbot/utils/filters.py index 41292db..f67c3cf 100644 --- a/app/supbot/utils/filters.py +++ b/app/supbot/utils/filters.py @@ -1,8 +1,11 @@ +from enum import StrEnum from typing import Any from aiogram import F from aiogram.filters import Command, Filter, or_f from aiogram.fsm.context import FSMContext +from filetype import filetype # type: ignore[import-untyped] +from filetype.types.archive import Pdf # type: ignore[import-untyped] from app.supbot import texts from app.supbot.models.support_db import SupportTicket @@ -30,6 +33,55 @@ async def __call__( # noqa: FNE005 return {"ticket": ticket} +class DocumentErrorType(StrEnum): + NO_DOCUMENT = "no_document" + WRONG_MIME_TYPE = "wrong_mime_type" + FILE_TO_LARGE = "file_to_large" + + +class DocumentFilter(Filter): + MAX_DOCUMENT_SIZE: int = 10 * 2**20 + + def __init__( + self, + expected_error: str | None = None, + max_size: int = MAX_DOCUMENT_SIZE, + mime_type: str = "application/pdf", + ) -> None: + self.expected_error = expected_error + self.max_size = max_size + self.mime_type = mime_type + + async def __call__( # noqa: FNE005 + self, + message: MessageExt, + ) -> bool | dict[str, Any]: + if message.document is None: + return self.expected_error == DocumentErrorType.NO_DOCUMENT + + if message.document.mime_type != self.mime_type: + return self.expected_error == DocumentErrorType.WRONG_MIME_TYPE + if ( + message.document.file_size is None + or message.document.file_size >= self.max_size + ): + return self.expected_error == DocumentErrorType.FILE_TO_LARGE + + content = await message.bot.download(message.document.file_id) + + match self.mime_type: + case "application/pdf": + if not filetype.match(content, [Pdf()]): + return self.expected_error == DocumentErrorType.WRONG_MIME_TYPE + return { + "document_data": ( + message.document.file_name, + content, + message.document.mime_type, + ) + } + + def command_filter(command: str) -> Filter: return or_f( Command(command), diff --git a/app/users/dependencies/forms_dep.py b/app/users/dependencies/forms_dep.py new file mode 100644 index 0000000..0b2b9a8 --- /dev/null +++ b/app/users/dependencies/forms_dep.py @@ -0,0 +1,21 @@ +from typing import Annotated + +from fastapi import Depends, UploadFile +from filetype import filetype # type: ignore[import-untyped] +from filetype.types.archive import Pdf # type: ignore[import-untyped] + +from app.common.fastapi_ext import Responses, with_responses + + +class ResumeFileResponses(Responses): + WRONG_FORMAT = 415, "Invalid file format" + + +@with_responses(ResumeFileResponses) +async def validate_resume_file(resume: UploadFile) -> UploadFile: + if not filetype.match(resume.file, [Pdf()]): + raise ResumeFileResponses.WRONG_FORMAT + return resume + + +ResumeFile = Annotated[UploadFile, Depends(validate_resume_file)] diff --git a/app/users/routes/forms_rst.py b/app/users/routes/forms_rst.py index da4d8b5..967c34d 100644 --- a/app/users/routes/forms_rst.py +++ b/app/users/routes/forms_rst.py @@ -2,14 +2,12 @@ from typing import Annotated, BinaryIO from discord_webhook import AsyncDiscordWebhook -from fastapi import File, HTTPException, UploadFile -from filetype import filetype # type: ignore[import-untyped] -from filetype.types.archive import Pdf # type: ignore[import-untyped] -from pydantic import BaseModel, Field +from fastapi import Form, HTTPException from app.common.config import settings -from app.common.fastapi_ext import APIRouterExt, Responses -from app.common.schemas.vacancy_form_sch import VacancyFormSchema +from app.common.fastapi_ext import APIRouterExt +from app.common.schemas.demo_form_sch import DemoFormSchema +from app.users.dependencies.forms_dep import ResumeFile router = APIRouterExt(tags=["forms"]) @@ -31,11 +29,6 @@ async def execute_discord_webhook( (await webhook.execute()).raise_for_status() -class DemoFormSchema(BaseModel): - name: str - contacts: Annotated[list[str], Field(min_length=1)] - - @router.post( "/demo-applications/", status_code=204, summary="Apply for a demonstration" ) @@ -49,29 +42,6 @@ async def apply_for_demonstration(demo_form: DemoFormSchema) -> None: ) -@router.post( - "/vacancy-applications/", - status_code=204, - summary="Use POST /api/v2/vacancy-applications/ instead", - deprecated=True, -) -async def apply_for_vacancy_old(vacancy_form: VacancyFormSchema) -> None: - content = ( - f"**Новый отклик на вакансию {vacancy_form.position}**\n" - + f"- Имя: {vacancy_form.name}\n" - + f"- Телеграм: {vacancy_form.telegram}\n" - + f"- [Резюме](<{vacancy_form.link}>)\n" - ) - if vacancy_form.message is not None: - content = f"{content}>>> {vacancy_form.message}" - - await execute_discord_webhook(url=settings.vacancy_webhook_url, content=content) - - -class FileFormatResponses(Responses): - WRONG_FORMAT = 415, "Invalid file format" - - def iter_vacancy_message_lines( position: str, name: str, telegram: str, message: str | None ) -> Iterator[str]: @@ -86,15 +56,12 @@ def iter_vacancy_message_lines( "/v2/vacancy-applications/", status_code=204, summary="Apply for a vacancy" ) async def apply_for_vacancy( - position: Annotated[str, File()], - name: Annotated[str, File()], - telegram: Annotated[str, File()], - resume: UploadFile, - message: Annotated[str | None, File()] = None, + position: Annotated[str, Form()], + name: Annotated[str, Form()], + telegram: Annotated[str, Form()], + resume: ResumeFile, + message: Annotated[str | None, Form()] = None, ) -> None: - if not filetype.match(resume.file, [Pdf()]): - raise FileFormatResponses.WRONG_FORMAT.value - await execute_discord_webhook( url=settings.vacancy_webhook_url, content="\n".join( diff --git a/poetry.lock b/poetry.lock index a863b66..34e2fbe 100644 --- a/poetry.lock +++ b/poetry.lock @@ -879,22 +879,23 @@ xlsx = ["openpyxl", "tablib"] [[package]] name = "fastapi" -version = "0.100.1" +version = "0.115.12" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "fastapi-0.100.1-py3-none-any.whl", hash = "sha256:ec6dd52bfc4eff3063cfcd0713b43c87640fefb2687bbbe3d8a08d94049cdf32"}, - {file = "fastapi-0.100.1.tar.gz", hash = "sha256:522700d7a469e4a973d92321ab93312448fbe20fca9c8da97effc7e7bc56df23"}, + {file = "fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d"}, + {file = "fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681"}, ] [package.dependencies] -pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<3.0.0" -starlette = ">=0.27.0,<0.28.0" -typing-extensions = ">=4.5.0" +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" +starlette = ">=0.40.0,<0.47.0" +typing-extensions = ">=4.8.0" [package.extras] -all = ["email-validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.5)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] [[package]] name = "filelock" @@ -3155,20 +3156,20 @@ sqlcipher = ["sqlcipher3_binary"] [[package]] name = "starlette" -version = "0.27.0" +version = "0.46.1" description = "The little ASGI library that shines." optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" files = [ - {file = "starlette-0.27.0-py3-none-any.whl", hash = "sha256:918416370e846586541235ccd38a474c08b80443ed31c578a418e2209b3eef91"}, - {file = "starlette-0.27.0.tar.gz", hash = "sha256:6a6b0d042acb8d469a01eba54e9cda6cbd24ac602c4cd016723117d6a7e73b75"}, + {file = "starlette-0.46.1-py3-none-any.whl", hash = "sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227"}, + {file = "starlette-0.46.1.tar.gz", hash = "sha256:3c88d58ee4bd1bb807c0d1acb381838afc7752f9ddaec81bbe4383611d833230"}, ] [package.dependencies] -anyio = ">=3.4.0,<5" +anyio = ">=3.6.2,<5" [package.extras] -full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyaml"] +full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] [[package]] name = "stevedore" @@ -3626,4 +3627,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "b6be475f4092fae5140011c786dd7885b64ac57e2707af38fc6fc79df7c909d1" +content-hash = "713aaca33d6eca918a7ef3831e1020c6e3cdd63facf912230ef07878f184deed" diff --git a/pyproject.toml b/pyproject.toml index b211995..4495dce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ readme = "README.md" [tool.poetry.dependencies] python = "^3.11" -fastapi = "^0.100.0" +fastapi = "^0.115.12" uvicorn = {extras = ["standard"], version = "^0.22.0"} sqlalchemy = {extras = ["asyncio"], version = "^2.0.18"} pydantic-marshals = "0.3.12" diff --git a/tests/conftest.py b/tests/conftest.py index bce8549..f63aa9c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,11 @@ from collections.abc import AsyncIterator, Iterator +from typing import Any, BinaryIO import pytest +from faker import Faker +from faker_file.providers.pdf_file.generators.pil_generator import ( # type: ignore[import-untyped] + PilPdfGenerator, +) from sqlalchemy import delete from starlette.testclient import TestClient @@ -43,3 +48,22 @@ def mub_client(client: TestClient) -> TestClient: base_url=f"http://{settings.cookie_domain}", headers={"X-MUB-Secret": settings.mub_key}, ) + + +@pytest.fixture() +async def pdf_data(faker: Faker) -> tuple[str, BinaryIO, str]: + return ( + faker.file_name(extension="pdf"), + faker.pdf_file(raw=True, pdf_generator_cls=PilPdfGenerator), + "application/pdf", + ) + + +@pytest.fixture() +def vacancy_form_data(faker: Faker) -> dict[str, Any]: + return { + "position": faker.sentence(nb_words=2), + "name": faker.name(), + "telegram": faker.url(), + "message": faker.sentence(), + } diff --git a/tests/supbot/factories.py b/tests/supbot/factories.py index addb632..8521e97 100644 --- a/tests/supbot/factories.py +++ b/tests/supbot/factories.py @@ -1,6 +1,6 @@ from typing import Generic, TypeVar -from aiogram.types import ChatMemberUpdated, Message, Update, User +from aiogram.types import ChatMemberUpdated, Document, Message, Update, User from polyfactory.factories.pydantic_factory import ModelFactory from pydantic import BaseModel @@ -26,3 +26,7 @@ class UserFactory(ModelFactory[User]): class ChatMemberUpdatedFactory(BaseModelFactory[ChatMemberUpdated]): __model__ = ChatMemberUpdated + + +class DocumentFactory(ModelFactory[Document]): + __model__ = Document diff --git a/tests/supbot/test_vacancy.py b/tests/supbot/test_vacancy.py index 6556cb5..736b02d 100644 --- a/tests/supbot/test_vacancy.py +++ b/tests/supbot/test_vacancy.py @@ -1,6 +1,7 @@ -from typing import Any +from typing import Any, BinaryIO import pytest +from aiogram import Bot from aiogram.fsm.state import State from aiogram.fsm.storage.base import BaseStorage, StorageKey from aiogram.methods import SendMessage @@ -11,13 +12,20 @@ from app.supbot import texts from app.supbot.routers.vacancy_tgm import VacancyStates +from app.supbot.utils.filters import DocumentErrorType, DocumentFilter +from tests.common.mock_stack import MockStack from tests.common.respx_ext import assert_last_httpx_request from tests.supbot.conftest import ( EXPECTED_MAIN_MENU_KEYBOARD_MARKUP, MockedBot, WebhookUpdater, ) -from tests.supbot.factories import MessageFactory, UpdateFactory, UserFactory +from tests.supbot.factories import ( + DocumentFactory, + MessageFactory, + UpdateFactory, + UserFactory, +) NAVIGATION_KEYBOARD_MARKUP = { "keyboard": [ @@ -308,6 +316,8 @@ async def test_sending_telegram( @pytest.mark.anyio() async def test_sending_resume( faker: Faker, + mock_stack: MockStack, + pdf_data: tuple[str, bytes, str], webhook_updater: WebhookUpdater, mocked_bot: MockedBot, bot_storage: BaseStorage, @@ -316,12 +326,18 @@ async def test_sending_resume( tg_user_id: int, ) -> None: await bot_storage.set_state(bot_storage_key, VacancyStates.sending_resume) - resume: str = faker.url() + + mock_stack.enter_async_mock(Bot, "download", return_value=pdf_data[1]) webhook_updater( UpdateFactory.build( message=MessageFactory.build( - text=resume, + document=DocumentFactory.build( + file_name=pdf_data[0], + mime_type=pdf_data[2], + file_id=faker.uuid4(), + file_size=len(pdf_data[1]), + ), chat=Chat(id=tg_chat_id, type="private"), from_user=UserFactory.build(id=tg_user_id), ), @@ -329,7 +345,8 @@ async def test_sending_resume( ) assert await bot_storage.get_state(bot_storage_key) == VacancyStates.sending_comment - assert await bot_storage.get_data(bot_storage_key) == {"link": resume} + assert await bot_storage.get_data(bot_storage_key) == {"resume": pdf_data} + mocked_bot.assert_next_api_call( SendMessage, { @@ -338,7 +355,143 @@ async def test_sending_resume( "reply_markup": SENDING_INFO_KEYBOARD_MARKUP, }, ) + mocked_bot.assert_no_more_api_calls() + + +@pytest.mark.anyio() +async def test_sending_resume_unsupported_message( + faker: Faker, + webhook_updater: WebhookUpdater, + mocked_bot: MockedBot, + bot_storage: BaseStorage, + bot_storage_key: StorageKey, + tg_chat_id: int, + tg_user_id: int, +) -> None: + await bot_storage.set_state(bot_storage_key, VacancyStates.sending_resume) + + webhook_updater( + UpdateFactory.build( + message=MessageFactory.build( + text=faker.sentence(), + chat=Chat(id=tg_chat_id, type="private"), + from_user=UserFactory.build(id=tg_user_id), + ), + ) + ) + + assert await bot_storage.get_state(bot_storage_key) == VacancyStates.sending_resume + mocked_bot.assert_next_api_call( + SendMessage, + { + "chat_id": tg_chat_id, + "text": texts.VACANCY_NO_DOCUMENT_MESSAGE, + }, + ) + mocked_bot.assert_no_more_api_calls() + + +@pytest.mark.anyio() +async def test_sending_resume_invalid_file_format( + faker: Faker, + mock_stack: MockStack, + webhook_updater: WebhookUpdater, + mocked_bot: MockedBot, + bot_storage: BaseStorage, + bot_storage_key: StorageKey, + tg_chat_id: int, + tg_user_id: int, +) -> None: + await bot_storage.set_state(bot_storage_key, VacancyStates.sending_resume) + + mock_stack.enter_async_mock( + Bot, "download", return_value=faker.random.randbytes(100) + ) + + webhook_updater( + UpdateFactory.build( + message=MessageFactory.build( + document=DocumentFactory.build( + file_name=faker.file_name(extension="pdf"), + mime_type="application/pdf", + file_size=faker.random_int( + min=1, max=DocumentFilter.MAX_DOCUMENT_SIZE - 1 + ), + ), + chat=Chat(id=tg_chat_id, type="private"), + from_user=UserFactory.build(id=tg_user_id), + ), + ) + ) + + assert await bot_storage.get_state(bot_storage_key) == VacancyStates.sending_resume + + mocked_bot.assert_next_api_call( + SendMessage, + { + "chat_id": tg_chat_id, + "text": texts.VACANCY_UNSUPPORTED_DOCUMENT_TYPE_MESSAGE, + }, + ) + mocked_bot.assert_no_more_api_calls() + + +@pytest.mark.anyio() +@pytest.mark.parametrize( + ("document_error", "expected_message"), + [ + pytest.param( + DocumentErrorType.WRONG_MIME_TYPE, + texts.VACANCY_UNSUPPORTED_DOCUMENT_TYPE_MESSAGE, + id="wrong_mime_type", + ), + pytest.param( + DocumentErrorType.FILE_TO_LARGE, + texts.VACANCY_DOCUMENT_TOO_LARGE_MESSAGE, + id="file_to_large", + ), + ], +) +async def test_sending_resume_unsupported_document( + faker: Faker, + webhook_updater: WebhookUpdater, + mocked_bot: MockedBot, + bot_storage: BaseStorage, + bot_storage_key: StorageKey, + tg_chat_id: int, + tg_user_id: int, + document_error: DocumentErrorType, + expected_message: str, +) -> None: + await bot_storage.set_state(bot_storage_key, VacancyStates.sending_resume) + + if document_error == DocumentErrorType.WRONG_MIME_TYPE: + mime_type = faker.mime_type(category="audio") + file_size = faker.random_int(min=1, max=DocumentFilter.MAX_DOCUMENT_SIZE - 1) + elif document_error == DocumentErrorType.FILE_TO_LARGE: + mime_type = "application/pdf" + file_size = DocumentFilter.MAX_DOCUMENT_SIZE + faker.random_int(min=1) + + webhook_updater( + UpdateFactory.build( + message=MessageFactory.build( + document=DocumentFactory.build( + file_name=faker.file_name(extension="pdf"), + mime_type=mime_type, + file_size=file_size, + ), + chat=Chat(id=tg_chat_id, type="private"), + from_user=UserFactory.build(id=tg_user_id), + ), + ) + ) + + assert await bot_storage.get_state(bot_storage_key) == VacancyStates.sending_resume + + mocked_bot.assert_next_api_call( + SendMessage, {"chat_id": tg_chat_id, "text": expected_message} + ) mocked_bot.assert_no_more_api_calls() @@ -353,6 +506,8 @@ async def test_sending_resume( async def test_sending_comment( faker: Faker, users_respx_mock: MockRouter, + pdf_data: tuple[str, BinaryIO, str], + vacancy_form_data: dict[str, Any], webhook_updater: WebhookUpdater, mocked_bot: MockedBot, bot_storage: BaseStorage, @@ -361,25 +516,27 @@ async def test_sending_comment( tg_user_id: int, is_comment_provided: bool, ) -> None: + if not is_comment_provided: + vacancy_form_data["message"] = None + public_users_bridge_mock = users_respx_mock.post( - path="/api/vacancy-applications/", + path="/api/v2/vacancy-applications/", + data=vacancy_form_data, + files={"resume": pdf_data}, ).respond(status_code=204) - data: dict[str, Any] = { - "name": faker.name(), - "position": faker.sentence(nb_words=2), - "telegram": faker.url(), - "link": faker.url(), - } - if is_comment_provided: - data["message"] = faker.sentence(nb_words=5) - await bot_storage.update_data(bot_storage_key, data) + vacancy_form_data["resume"] = pdf_data + await bot_storage.update_data(bot_storage_key, vacancy_form_data) await bot_storage.set_state(bot_storage_key, VacancyStates.sending_comment) webhook_updater( UpdateFactory.build( message=MessageFactory.build( - text=data["message"] if is_comment_provided else texts.SKIP_BUTTON_TEXT, + text=( + vacancy_form_data["message"] + if is_comment_provided + else texts.SKIP_BUTTON_TEXT + ), chat=Chat(id=tg_chat_id, type="private"), from_user=UserFactory.build(id=tg_user_id), ), @@ -400,8 +557,7 @@ async def test_sending_comment( assert_last_httpx_request( public_users_bridge_mock, - expected_path="/api/vacancy-applications/", - expected_json=data, + expected_path="/api/v2/vacancy-applications/", ) @@ -483,7 +639,6 @@ async def test_going_back( pytest.param(VacancyStates.sending_specialization, id="sending_specialization"), pytest.param(VacancyStates.sending_name, id="sending_name"), pytest.param(VacancyStates.sending_telegram, id="sending_telegram"), - pytest.param(VacancyStates.sending_resume, id="sending_resume"), pytest.param(VacancyStates.sending_comment, id="sending_comment"), ], ) diff --git a/tests/users/conftest.py b/tests/users/conftest.py index 784439b..79a97e3 100644 --- a/tests/users/conftest.py +++ b/tests/users/conftest.py @@ -166,14 +166,3 @@ def invalid_mub_key_headers( if request.param: return {"X-MUB-Secret": faker.pystr()} return None - - -@pytest.fixture() -def vacancy_form_data(faker: Faker) -> dict[str, Any]: - return { - "name": faker.name(), - "position": faker.sentence(nb_words=2), - "telegram": faker.url(), - "link": faker.url(), - "message": faker.sentence(), - } diff --git a/tests/users/test_forms.py b/tests/users/test_forms.py index 50e4041..fe7ea48 100644 --- a/tests/users/test_forms.py +++ b/tests/users/test_forms.py @@ -1,12 +1,9 @@ -from typing import Any +from typing import Any, BinaryIO from unittest.mock import Mock import pytest from discord_webhook import AsyncDiscordWebhook from faker import Faker -from faker_file.providers.pdf_file.generators.pil_generator import ( # type: ignore[import-untyped] - PilPdfGenerator, -) from starlette.testclient import TestClient from tests.common.assert_contains_ext import assert_nodata_response, assert_response @@ -56,51 +53,12 @@ async def test_demo_form_submitting_missing_webhook_url( ) -@pytest.mark.anyio() -async def test_old_vacancy_form_submitting( - faker: Faker, - mock_stack: MockStack, - client: TestClient, - vacancy_form_data: dict[str, Any], -) -> None: - response_mock = Mock() - response_mock.raise_for_status = Mock() - execute_mock = mock_stack.enter_async_mock( - AsyncDiscordWebhook, "execute", return_value=response_mock - ) - mock_stack.enter_mock( - "app.users.routes.forms_rst.settings.vacancy_webhook_url", return_value="" - ) - - assert_nodata_response( - client.post("/api/vacancy-applications/", json=vacancy_form_data) - ) - execute_mock.assert_called_once() - response_mock.raise_for_status.assert_called_once() - - -@pytest.mark.anyio() -async def test_old_vacancy_form_submitting_missing_webhook_url( - faker: Faker, client: TestClient, vacancy_form_data: dict[str, Any] -) -> None: - assert_response( - client.post("/api/vacancy-applications/", json=vacancy_form_data), - expected_code=500, - expected_json={"detail": "Webhook url is not set"}, - ) - - -@pytest.fixture() -async def pdf(faker: Faker) -> bytes: - return faker.pdf_file(raw=True, pdf_generator_cls=PilPdfGenerator) # type: ignore[no-any-return] - - @pytest.mark.anyio() async def test_vacancy_form_submitting( mock_stack: MockStack, client: TestClient, + pdf_data: tuple[str, BinaryIO, str], vacancy_form_data: dict[str, Any], - pdf: bytes, ) -> None: response_mock = Mock() response_mock.raise_for_status = Mock() @@ -115,7 +73,7 @@ async def test_vacancy_form_submitting( client.post( "/api/v2/vacancy-applications/", data=vacancy_form_data, - files={"resume": ("resume.pdf", pdf, "application/pdf")}, + files={"resume": pdf_data}, ) ) execute_mock.assert_called_once() @@ -141,13 +99,15 @@ async def test_vacancy_form_submitting_invalid_file_format( @pytest.mark.anyio() async def test_vacancy_form_submitting_missing_webhook_url( - client: TestClient, vacancy_form_data: dict[str, Any], pdf: bytes + client: TestClient, + pdf_data: tuple[str, BinaryIO, str], + vacancy_form_data: dict[str, Any], ) -> None: assert_response( client.post( "/api/v2/vacancy-applications/", data=vacancy_form_data, - files={"resume": ("resume.pdf", pdf, "application/pdf")}, + files={"resume": pdf_data}, ), expected_code=500, expected_json={"detail": "Webhook url is not set"},