diff --git a/.github/workflows/build_and_publish.yml b/.github/workflows/build_and_publish.yml index cdaac9b..c45e84e 100644 --- a/.github/workflows/build_and_publish.yml +++ b/.github/workflows/build_and_publish.yml @@ -108,6 +108,7 @@ jobs: --env ALLOW_STUDENT_NUMBER=true \ --env STATIC_FOLDER=/app/static \ --env STORAGE_TIME=30 \ + --env MAX_PAGE_COUNT=20 \ --env AUTH_URL=https://auth.api.test.profcomff.com/ \ --name ${{ env.CONTAITER_NAME }} \ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:test @@ -174,6 +175,7 @@ jobs: --env ALLOW_STUDENT_NUMBER=true \ --env STATIC_FOLDER=/app/static \ --env STORAGE_TIME=168 \ + --env MAX_PAGE_COUNT=20 \ --env AUTH_URL=https://auth.api.profcomff.com/ \ --name ${{ env.CONTAITER_NAME }} \ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest diff --git a/migrations/versions/d63e9f7661dd_page_count.py b/migrations/versions/d63e9f7661dd_page_count.py new file mode 100644 index 0000000..0c56538 --- /dev/null +++ b/migrations/versions/d63e9f7661dd_page_count.py @@ -0,0 +1,24 @@ +"""page_count + +Revision ID: d63e9f7661dd +Revises: f6fb6304fb74 +Create Date: 2023-05-15 18:38:40.964981 + +""" +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision = 'd63e9f7661dd' +down_revision = 'f6fb6304fb74' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('print_fact', sa.Column('sheets_used', sa.Integer(), nullable=True)) + + +def downgrade(): + op.drop_column('print_fact', 'sheets_used') diff --git a/print_service/base.py b/print_service/base.py new file mode 100644 index 0000000..b600580 --- /dev/null +++ b/print_service/base.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel + + +class Base(BaseModel): + def __repr__(self) -> str: + attrs = [] + for k, v in self.__class__.schema().items(): + attrs.append(f"{k}={v}") + return "{}({})".format(self.__class__.__name__, ', '.join(attrs)) + + class Config: + orm_mode = True + + +class StatusResponseModel(Base): + status: str + message: str diff --git a/print_service/exceptions.py b/print_service/exceptions.py new file mode 100644 index 0000000..99ed16a --- /dev/null +++ b/print_service/exceptions.py @@ -0,0 +1,92 @@ +from print_service.settings import get_settings + + +settings = get_settings() + + +class ObjectNotFound(Exception): + pass + + +class TerminalTokenNotFound(ObjectNotFound): + pass + + +class TerminalQRNotFound(ObjectNotFound): + pass + + +class PINNotFound(ObjectNotFound): + def __init__(self, pin: str): + self.pin = pin + + +class UserNotFound(ObjectNotFound): + pass + + +class FileNotFound(ObjectNotFound): + def __init__(self, count: int): + self.count = count + + +class TooManyPages(Exception): + def __init__(self): + super().__init__(f'Content too large, count of page: {settings.MAX_PAGE_COUNT} is allowed') + + +class TooLargeSize(Exception): + def __init__(self): + super().__init__(f'Content too large, {settings.MAX_SIZE} bytes allowed') + + +class InvalidPageRequest(Exception): + def __init__(self): + super().__init__(f'Invalid format') + + +class UnionStudentDuplicate(Exception): + def __init__(self): + super().__init__('Duplicates by union_numbers or student_numbers') + + +class NotInUnion(Exception): + def __init__(self): + super().__init__(f'User is not found in trade union list') + + +class PINGenerateError(Exception): + def __init__(self): + super().__init__(f'Can not generate PIN. Too many users?') + + +class FileIsNotReceived(Exception): + def __init__(self): + super().__init__(f'No file was recieved') + + +class InvalidType(Exception): + def __init__(self, content_type: str): + super().__init__( + f'Only {", ".join(settings.CONTENT_TYPES)} files allowed, but {content_type} was recieved' + ) + + +class AlreadyUploaded(Exception): + def __init__(self): + super().__init__(f'File has been already uploaded') + + +class IsCorrupted(Exception): + def __init__(self): + super().__init__(f'File is corrupted') + + +class IsNotUploaded(Exception): + def __init__(self): + super().__init__(f'File has not been uploaded yet') + + +class UnprocessableFileInstance(Exception): + def __init__(self): + super().__init__(f'Unprocessable file instance') diff --git a/print_service/models/__init__.py b/print_service/models/__init__.py index 7df12c5..0d6538b 100644 --- a/print_service/models/__init__.py +++ b/print_service/models/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +import math from datetime import datetime from sqlalchemy import Column, DateTime, Integer, String @@ -45,6 +46,37 @@ class File(Model): owner: Mapped[UnionMember] = relationship('UnionMember', back_populates='files') print_facts: Mapped[list[PrintFact]] = relationship('PrintFact', back_populates='file') + @property + def flatten_pages(self) -> list[int] | None: + '''Возвращает расширенный список из элементов списков внутренних целочисленных точек переданного множества отрезков + "1-5, 3, 2" --> [1, 2, 3, 4, 5, 3, 2]''' + if self.number_of_pages is None: + return None + result = list() + if self.option_pages == '': + return result + for part in self.option_pages.split(','): + x = part.split('-') + result.extend(range(int(x[0]), int(x[-1]) + 1)) + return result + + @property + def sheets_count(self) -> int | None: + '''Возвращает количество элементов списков внутренних целочисленных точек переданного множества отрезков + "1-5, 3, 2" --> 7 + P.S. 1, 2, 3, 4, 5, 3, 2 -- 7 чисел''' + if self.number_of_pages is None: + return None + if not self.flatten_pages: + return ( + math.ceil(self.number_of_pages - (self.option_two_sided * self.number_of_pages / 2)) + * self.option_copies + ) + if self.option_two_sided: + return math.ceil(len(self.flatten_pages) / 2) * self.option_copies + else: + return len(self.flatten_pages) * self.option_copies + class PrintFact(Model): __tablename__ = 'print_fact' @@ -56,3 +88,5 @@ class PrintFact(Model): owner: Mapped[UnionMember] = relationship('UnionMember', back_populates='print_facts') file: Mapped[File] = relationship('File', back_populates='print_facts') + + sheets_used: Mapped[int] = Column(Integer) diff --git a/print_service/routes/__init__.py b/print_service/routes/__init__.py index 9b3a8a0..5e4fcb1 100644 --- a/print_service/routes/__init__.py +++ b/print_service/routes/__init__.py @@ -1 +1,5 @@ +from . import exc_handlers from .base import app + + +__all__ = ["app", "exc_handlers"] diff --git a/print_service/routes/admin.py b/print_service/routes/admin.py index 2a6835c..fa9436e 100644 --- a/print_service/routes/admin.py +++ b/print_service/routes/admin.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException from redis import Redis +from print_service.exceptions import TerminalTokenNotFound from print_service.schema import BaseModel from print_service.settings import Settings, get_settings @@ -52,7 +53,7 @@ async def manual_update_terminal( sender.redis.close() return {'status': 'ok'} sender.redis.close() - raise HTTPException(400, 'Terminal not found by token') + raise TerminalTokenNotFound() @router.post("/reboot") @@ -65,4 +66,4 @@ async def reboot_terminal( sender.redis.close() return {'status': 'ok'} sender.redis.close() - raise HTTPException(400, 'Terminal not found by token') + raise TerminalTokenNotFound() diff --git a/print_service/routes/exc_handlers.py b/print_service/routes/exc_handlers.py new file mode 100644 index 0000000..63d286e --- /dev/null +++ b/print_service/routes/exc_handlers.py @@ -0,0 +1,147 @@ +import starlette.requests +from starlette.responses import JSONResponse + +from print_service.base import StatusResponseModel +from print_service.exceptions import ( + AlreadyUploaded, + FileIsNotReceived, + FileNotFound, + InvalidPageRequest, + InvalidType, + IsCorrupted, + IsNotUploaded, + NotInUnion, + PINGenerateError, + PINNotFound, + TerminalQRNotFound, + TerminalTokenNotFound, + TooLargeSize, + TooManyPages, + UnionStudentDuplicate, + UnprocessableFileInstance, + UserNotFound, +) +from print_service.routes.base import app + + +@app.exception_handler(TooLargeSize) +async def too_large_size(req: starlette.requests.Request, exc: TooLargeSize): + return JSONResponse( + content=StatusResponseModel(status="Error", message=f"{exc}").dict(), status_code=413 + ) + + +@app.exception_handler(TooManyPages) +async def too_many_pages(req: starlette.requests.Request, exc: TooManyPages): + return JSONResponse( + content=StatusResponseModel(status="Error", message=f"{exc}").dict(), status_code=413 + ) + + +@app.exception_handler(InvalidPageRequest) +async def invalid_format(req: starlette.requests.Request, exc: TooManyPages): + return JSONResponse( + content=StatusResponseModel(status="Error", message=f"{exc}").dict(), status_code=416 + ) + + +@app.exception_handler(TerminalQRNotFound) +async def terminal_not_found_by_qr(req: starlette.requests.Request, exc: TerminalQRNotFound): + return JSONResponse( + content=StatusResponseModel(status="Error", message=f"Terminal not found by QR").dict(), + status_code=400, + ) + + +@app.exception_handler(TerminalTokenNotFound) +async def terminal_not_found_by_token(req: starlette.requests.Request, exc: TerminalTokenNotFound): + return JSONResponse( + content=StatusResponseModel(status="Error", message=f"Terminal not found by token").dict(), + status_code=400, + ) + + +@app.exception_handler(UserNotFound) +async def user_not_found(req: starlette.requests.Request, exc: UserNotFound): + return JSONResponse( + content=StatusResponseModel(status="Error", message=f"User not found").dict(), status_code=404 + ) + + +@app.exception_handler(UnionStudentDuplicate) +async def student_duplicate(req: starlette.requests.Request, exc: UnionStudentDuplicate): + return JSONResponse( + content=StatusResponseModel(status="Error", message=f"{exc}").dict(), status_code=400 + ) + + +@app.exception_handler(NotInUnion) +async def not_in_union(req: starlette.requests.Request, exc: NotInUnion): + return JSONResponse( + content=StatusResponseModel(status="Error", message=f"{exc}").dict(), status_code=403 + ) + + +@app.exception_handler(PINGenerateError) +async def generate_error(req: starlette.requests.Request, exc: PINGenerateError): + return JSONResponse( + content=StatusResponseModel(status="Error", message=f"{exc}").dict(), status_code=500 + ) + + +@app.exception_handler(FileIsNotReceived) +async def file_not_received(req: starlette.requests.Request, exc: FileIsNotReceived): + return JSONResponse( + content=StatusResponseModel(status="Error", message=f"{exc}").dict(), status_code=400 + ) + + +@app.exception_handler(PINNotFound) +async def pin_not_found(req: starlette.requests.Request, exc: PINNotFound): + return JSONResponse( + content=StatusResponseModel(status="Error", message=f"Pin {exc.pin} not found").dict(), + status_code=404, + ) + + +@app.exception_handler(InvalidType) +async def invalid_type(req: starlette.requests.Request, exc: InvalidType): + return JSONResponse( + content=StatusResponseModel(status="Error", message=f"{exc}").dict(), status_code=415 + ) + + +@app.exception_handler(AlreadyUploaded) +async def already_upload(req: starlette.requests.Request, exc: AlreadyUploaded): + return JSONResponse( + content=StatusResponseModel(status="Error", message=f"{exc}").dict(), status_code=415 + ) + + +@app.exception_handler(IsCorrupted) +async def is_corrupted(req: starlette.requests.Request, exc: IsCorrupted): + return JSONResponse( + content=StatusResponseModel(status="Error", message=f"{exc}").dict(), status_code=415 + ) + + +@app.exception_handler(UnprocessableFileInstance) +async def unprocessable_file_instance(req: starlette.requests.Request, exc: UnprocessableFileInstance): + return JSONResponse( + content=StatusResponseModel(status="Error", message=f"{exc}").dict(), status_code=422 + ) + + +@app.exception_handler(FileNotFound) +async def file_not_found(req: starlette.requests.Request, exc: FileNotFound): + return JSONResponse( + content=StatusResponseModel(status="Error", message=f"{exc.count} file(s) not found").dict(), + status_code=404, + ) + + +@app.exception_handler(IsNotUploaded) +async def not_uploaded(req: starlette.requests.Request, exc: IsNotUploaded): + return JSONResponse( + content=StatusResponseModel(status="Error", message=f"{exc}").dict(), status_code=415 + ) diff --git a/print_service/routes/file.py b/print_service/routes/file.py index 7da7ae0..b8208c5 100644 --- a/print_service/routes/file.py +++ b/print_service/routes/file.py @@ -11,6 +11,20 @@ from pydantic import Field, validator from sqlalchemy import func, or_ +from print_service.exceptions import ( + AlreadyUploaded, + FileIsNotReceived, + InvalidPageRequest, + InvalidType, + IsCorrupted, + NotInUnion, + PINGenerateError, + PINNotFound, + TooLargeSize, + TooManyPages, + UnprocessableFileInstance, + UserNotFound, +) from print_service.models import File as FileModel from print_service.models import UnionMember from print_service.schema import BaseModel @@ -106,11 +120,11 @@ async def send(inp: SendInput, settings: Settings = Depends(get_settings)): func.upper(UnionMember.surname) == inp.surname.upper(), ).one_or_none() if not user: - raise HTTPException(403, 'User not found in trade union list') + raise NotInUnion() try: pin = generate_pin(db.session) except RuntimeError: - raise HTTPException(500, 'Can not generate PIN. Too many users?') + raise PINGenerateError filename = generate_filename(inp.filename) file_model = FileModel(pin=pin, file=filename) file_model.owner = user @@ -148,7 +162,7 @@ async def upload_file( (меняется в настройках сервера). """ if file == ...: - raise HTTPException(400, 'No file recieved') + raise FileIsNotReceived() file_model = ( db.session.query(FileModel) .filter(func.upper(FileModel.pin) == pin.upper()) @@ -156,29 +170,37 @@ async def upload_file( .one_or_none() ) if not file_model: - raise HTTPException(404, f'Pin {pin} not found') - + await file.close() + raise PINNotFound(pin) if file.content_type not in settings.CONTENT_TYPES: - raise HTTPException( - 415, - f'Only {", ".join(settings.CONTENT_TYPES)} files allowed, but {file.content_type} recieved', - ) - + raise InvalidType() path = abspath(settings.STATIC_FOLDER) + '/' + file_model.file if exists(path): - raise HTTPException(415, 'File already uploaded') + await file.close() + raise AlreadyUploaded() async with aiofiles.open(path, 'wb') as saved_file: memory_file = await file.read() if len(memory_file) > settings.MAX_SIZE: - raise HTTPException(413, f'Content too large, {settings.MAX_SIZE} bytes allowed') + await file.close() + raise TooLargeSize() await saved_file.write(memory_file) pdf_ok, number_of_pages = checking_for_pdf(memory_file) file_model.number_of_pages = number_of_pages db.session.commit() if not pdf_ok: await aiofiles.os.remove(path) - raise HTTPException(415, 'File corrupted') + await file.close() + raise IsCorrupted() + if file_model.flatten_pages: + if number_of_pages < max(file_model.flatten_pages): + await aiofiles.os.remove(path) + await file.close() + raise InvalidPageRequest() + if file_model.sheets_count > settings.MAX_PAGE_COUNT: + await aiofiles.os.remove(path) + await file.close() + raise TooManyPages() await file.close() return { @@ -214,13 +236,18 @@ async def update_file_options( ) print(options) if not file_model: - raise HTTPException(404, f'Pin {pin} not found') + raise PINNotFound(pin) file_model.option_pages = options.get('pages') or file_model.option_pages file_model.option_copies = options.get('copies') or file_model.option_copies file_model.option_two_sided = ( v if (v := options.get('two_sided')) is not None else file_model.option_two_sided ) db.session.commit() + if file_model.flatten_pages: + if file_model.number_of_pages < max(file_model.flatten_pages): + raise InvalidPageRequest + if file_model.sheets_count > settings.MAX_PAGE_COUNT: + raise TooManyPages() return { 'pin': file_model.pin, 'options': { diff --git a/print_service/routes/qrprint.py b/print_service/routes/qrprint.py index ecac611..a482d57 100644 --- a/print_service/routes/qrprint.py +++ b/print_service/routes/qrprint.py @@ -9,6 +9,7 @@ from pydantic import conlist from redis import Redis +from print_service.exceptions import FileNotFound, InvalidPageRequest, IsNotUploaded, TerminalQRNotFound from print_service.schema import BaseModel from print_service.settings import Settings, get_settings from print_service.utils import get_file @@ -92,7 +93,7 @@ async def instant_print(options: InstantPrintCreate): options.qr_token = options.qr_token.removeprefix(settings.QR_TOKEN_PREFIX) if redis_conn.send(**options.dict()): return {'status': 'ok'} - raise HTTPException(400, 'Terminal not found by qr') + raise TerminalQRNotFound() @router.websocket("") diff --git a/print_service/routes/user.py b/print_service/routes/user.py index f627613..4ac1654 100644 --- a/print_service/routes/user.py +++ b/print_service/routes/user.py @@ -9,6 +9,7 @@ from sqlalchemy import and_, func, or_ from print_service import __version__ +from print_service.exceptions import UnionStudentDuplicate, UserNotFound from print_service.models import UnionMember from print_service.schema import BaseModel from print_service.settings import get_settings @@ -62,7 +63,7 @@ async def check_union_member( return bool(user) if not user: - raise HTTPException(404, 'User not found') + raise UserNotFound() else: return { 'surname': user.surname, @@ -85,9 +86,7 @@ def update_list( if len(union_numbers) != len(set(union_numbers)) or len(student_numbers) != len( set(student_numbers) ): - raise HTTPException( - 400, {"status": "error", "detail": "Duplicates by union_numbers or student_numbers"} - ) + raise UnionStudentDuplicate() for user in input.users: db_user: UnionMember = ( diff --git a/print_service/settings.py b/print_service/settings.py index ab62952..331c29b 100644 --- a/print_service/settings.py +++ b/print_service/settings.py @@ -16,6 +16,7 @@ class Settings(UnionAuthSettings, BaseSettings): CONTENT_TYPES: List[str] = ['application/pdf'] MAX_SIZE: int = 5000000 # Максимальный размер файла в байтах + MAX_PAGE_COUNT: int = 20 STORAGE_TIME: int = 7 * 24 # Время хранения файла в часах STATIC_FOLDER: DirectoryPath | None diff --git a/print_service/utils/__init__.py b/print_service/utils/__init__.py index 9f32af1..1c44b33 100644 --- a/print_service/utils/__init__.py +++ b/print_service/utils/__init__.py @@ -1,4 +1,5 @@ import io +import math import random import re from datetime import date, datetime, timedelta @@ -10,9 +11,16 @@ from sqlalchemy import func from sqlalchemy.orm.session import Session +from print_service.exceptions import ( + FileNotFound, + InvalidPageRequest, + IsNotUploaded, + UnprocessableFileInstance, +) from print_service.models import File from print_service.models import File as FileModel from print_service.models import PrintFact +from print_service.routes import exc_handlers from print_service.settings import Settings, get_settings @@ -41,7 +49,7 @@ def generate_filename(original_filename: str): salt = ''.join(random.choice(settings.PIN_SYMBOLS) for _ in range(128)) ext_list = re.findall(r'\w+', original_filename.split('.')[-1]) if not ext_list: - raise HTTPException(422, "Unprocessable file instance") + raise UnprocessableFileInstance() ext = ext_list[0] return f'{datestr}-{salt}.{ext}' @@ -55,13 +63,13 @@ def get_file(dbsession, pin: str or list[str]): .all() ) if len(pin) != len(files): - raise HTTPException(404, f'{len(pin) - len(files)} file(s) not found') + raise FileNotFound(len(pin) - len(files)) result = [] for f in files: path = abspath(settings.STATIC_FOLDER) + '/' + f.file if not exists(path): - raise HTTPException(415, 'File has not uploaded yet') + raise IsNotUploaded() result.append( { @@ -73,7 +81,11 @@ def get_file(dbsession, pin: str or list[str]): }, } ) - file_model = PrintFact(file_id=f.id, owner_id=f.owner_id) + _, number_of_pages = checking_for_pdf(f) + if f.flatten_pages: + if number_of_pages > max(f.flatten_pages): + raise InvalidPageRequest() + file_model = PrintFact(file_id=f.id, owner_id=f.owner_id, sheets_used=f.sheets_count) dbsession.add(file_model) dbsession.commit() return result diff --git a/tests/test_routes/test_file.py b/tests/test_routes/test_file.py index 9a93245..a04465c 100644 --- a/tests/test_routes/test_file.py +++ b/tests/test_routes/test_file.py @@ -4,6 +4,7 @@ from fastapi import HTTPException from starlette import status +from print_service.exceptions import FileNotFound, InvalidPageRequest, IsNotUploaded from print_service.models import File from print_service.settings import get_settings from print_service.utils import checking_for_pdf, get_file @@ -56,13 +57,14 @@ def test_get_file_wrong_pin(uploaded_file_os, client): def test_get_file_func_1_not_exists(dbsession): - with pytest.raises(HTTPException): + with pytest.raises((FileNotFound, IsNotUploaded, InvalidPageRequest)): get_file(dbsession, ['1']) + assert FileNotFound dbsession.commit() def test_get_file_func_1_not_uploaded(dbsession, uploaded_file_db): - with pytest.raises(HTTPException): + with pytest.raises((FileNotFound, IsNotUploaded, InvalidPageRequest)): data = get_file(dbsession, [uploaded_file_db.pin]) dbsession.commit() @@ -82,7 +84,7 @@ def test_get_file_func_1_ok(dbsession, uploaded_file_os): def test_get_file_func_2_not_exists(dbsession, uploaded_file_os): - with pytest.raises(HTTPException): + with pytest.raises((FileNotFound, IsNotUploaded, InvalidPageRequest)): data = get_file(dbsession, [uploaded_file_os.pin, '1']) @@ -164,3 +166,35 @@ def test_incorrect_filename(union_member_user, client, dbsession): assert res3.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY res4 = client.post(url, data=json.dumps(body4)) assert res4.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + +def test_upload_big_file(pin_pdf, client): + fileName = 'tests/test_routes/test_files/many_pages.pdf' + files = {'file': (f"{fileName}", open(f"{fileName}", 'rb'), "application/pdf")} + max_page = get_settings().MAX_PAGE_COUNT + get_settings().MAX_PAGE_COUNT = 9 + res2 = client.post(f"{url}/{pin_pdf}", files=files) + assert res2.status_code == status.HTTP_413_REQUEST_ENTITY_TOO_LARGE + get_settings().MAX_PAGE_COUNT = 10 + res3 = client.post(f"{url}/{pin_pdf}", files=files) + assert res3.status_code == status.HTTP_200_OK + get_settings().MAX_PAGE_COUNT = 3 + payload = {"options": {"pages": "2-4,6", "copies": 1, "two_sided": False}} + res4 = client.patch(f"{url}/{pin_pdf}", json=payload) + assert res4.status_code == status.HTTP_413_REQUEST_ENTITY_TOO_LARGE + payload2 = {"options": {"pages": "1-3, 7", "copies": 2, "two_sided": False}} + get_settings().MAX_PAGE_COUNT = 7 + res5 = client.patch(f"{url}/{pin_pdf}", json=payload2) + assert res5.status_code == status.HTTP_413_REQUEST_ENTITY_TOO_LARGE + payload3 = {"options": {"pages": "1-3, 7", "copies": 2, "two_sided": True}} + get_settings().MAX_PAGE_COUNT = 3 + res6 = client.patch(f"{url}/{pin_pdf}", json=payload3) + assert res6.status_code == status.HTTP_413_REQUEST_ENTITY_TOO_LARGE + get_settings().MAX_PAGE_COUNT = 4 + res7 = client.patch(f"{url}/{pin_pdf}", json=payload3) + assert res7.status_code == status.HTTP_200_OK + get_settings().MAX_PAGE_COUNT = 2 + payload4 = {"options": {"pages": "1, 1, 1", "copies": 1, "two_sided": False}} + res8 = client.patch(f"{url}/{pin_pdf}", json=payload4) + assert res8.status_code == status.HTTP_413_REQUEST_ENTITY_TOO_LARGE + get_settings().MAX_PAGE_COUNT = max_page diff --git a/tests/test_routes/test_files/many_pages.pdf b/tests/test_routes/test_files/many_pages.pdf new file mode 100644 index 0000000..d4c44c8 Binary files /dev/null and b/tests/test_routes/test_files/many_pages.pdf differ