From 80911bd538016dcef08384ce1bac4938bb6f9a9b Mon Sep 17 00:00:00 2001 From: Pavel Sobolev Date: Thu, 13 Nov 2025 21:30:45 +0300 Subject: [PATCH] add tests --- .pre-commit-config.yaml | 13 ++++- cv/tests.py | 3 -- cv/tests/__init__.py | 1 + cv/tests/test_docx_renderer.py | 74 +++++++++++++++++++++++++++ cv/tests/test_pdf_renderer.py | 93 ++++++++++++++++++++++++++++++++++ cv/tests/test_views.py | 68 +++++++++++++++++++++++++ poetry.lock | 73 +++++++++++++++++++++++++- pyproject.toml | 2 + pytest.ini | 3 ++ resume/tests/__init__.py | 1 + resume/tests/test_media_tag.py | 39 ++++++++++++++ 11 files changed, 363 insertions(+), 7 deletions(-) delete mode 100644 cv/tests.py create mode 100644 cv/tests/__init__.py create mode 100644 cv/tests/test_docx_renderer.py create mode 100644 cv/tests/test_pdf_renderer.py create mode 100644 cv/tests/test_views.py create mode 100644 pytest.ini create mode 100644 resume/tests/__init__.py create mode 100644 resume/tests/test_media_tag.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 71fd8ee..739d362 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,7 +21,7 @@ repos: rev: 1.7.9 hooks: - id: bandit - args: ["-r",".","-x",".venv,venv,build,dist,.tox,.mypy_cache,.ruff_cache,node_modules"] + args: ["-r",".","-x",".venv,venv,build,dist,.tox,.mypy_cache,.ruff_cache,node_modules,**/tests/**"] additional_dependencies: - "python-dotenv>=1.0.1,<2.0.0" pass_filenames: false @@ -37,4 +37,13 @@ repos: rev: "v2.11" hooks: - id: vulture - args: [".","--exclude",".venv,venv,build,dist,.tox,.mypy_cache,.ruff_cache,node_modules,**/migrations/**","--min-confidence","80"] \ No newline at end of file + args: [".","--exclude",".venv,venv,build,dist,.tox,.mypy_cache,.ruff_cache,node_modules,**/migrations/**","--min-confidence","80"] + +- repo: local + hooks: + - id: pytest + name: pytest (unit+integration) + entry: poetry run pytest -q + language: system + pass_filenames: false + stages: [commit] \ No newline at end of file diff --git a/cv/tests.py b/cv/tests.py deleted file mode 100644 index d7d7981..0000000 --- a/cv/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Тесты приложения `cv`.""" - -# Create your tests here. diff --git a/cv/tests/__init__.py b/cv/tests/__init__.py new file mode 100644 index 0000000..8fa2e9b --- /dev/null +++ b/cv/tests/__init__.py @@ -0,0 +1 @@ +"""Тестовый пакет приложения `cv`.""" diff --git a/cv/tests/test_docx_renderer.py b/cv/tests/test_docx_renderer.py new file mode 100644 index 0000000..677f6bf --- /dev/null +++ b/cv/tests/test_docx_renderer.py @@ -0,0 +1,74 @@ +"""Тесты для `DocxRenderer` и сохранения DOCX в буфер.""" + +import logging +from io import BytesIO + +import pytest + +from cv.services.dowload.docx import DocxRenderer +from resume.utils.logging import configure_root_logger + +logger = logging.getLogger(__name__) +configure_root_logger() + + +@pytest.fixture +def profile() -> object: + """Минимальный фейковый профиль. + + Returns: + object: Объект с обязательными атрибутами и менеджерами all(). + """ + + class P: + def __init__(self) -> None: + self.full_name = "Jane Doe" + self.experience = type("Q", (), {"all": lambda self: []})() + self.skills_map = type("Q", (), {"all": lambda self: []})() + + return P() + + +def test_docx_render_unit(monkeypatch: pytest.MonkeyPatch, profile: object) -> None: + """Юнит: замокаем serialize и save, проверим байты и единичный вызов. + + Args: + monkeypatch (pytest.MonkeyPatch): Инструмент подмены атрибутов. + profile (object): Фейковый профиль. + """ + logger.info("Юнит-тест DOCX: проверяем проводку до save()") + + fake_serialized = { + "full_name": "Jane Doe", + "role": "Dev", + "summary": "Summary", + "location": "Earth", + "languages": ["EN"], + "contacts": {"email": "jane@example.com", "phone": "", "telegram": ""}, + "experience": [], + "skills_map": [], + } + expected_docx = b"PK\x03\x04FAKE-DOCX" # в юните можно вернуть фиктивные байты + + def fake_serialize(_self: object, _p: object) -> dict: + """Детерминированная сериализация.""" + return fake_serialized + + calls = {"save": 0} + + def fake_save(self: object, stream: BytesIO) -> None: # noqa: D417 + """Подмена docx.document.Document.save — пишет ожидаемые байты. + + Args: + stream (BytesIO): Целевой буфер. + """ + calls["save"] += 1 + stream.write(expected_docx) + + monkeypatch.setattr("cv.services.dowload.docx.ProfileSerializer.serialize", fake_serialize) + monkeypatch.setattr("docx.document.Document.save", fake_save, raising=True) + + out = DocxRenderer().render(profile) + assert isinstance(out, BytesIO) + assert out.getvalue() == expected_docx + assert calls["save"] == 1 diff --git a/cv/tests/test_pdf_renderer.py b/cv/tests/test_pdf_renderer.py new file mode 100644 index 0000000..ca60e2c --- /dev/null +++ b/cv/tests/test_pdf_renderer.py @@ -0,0 +1,93 @@ +"""Тесты для `PdfRenderer` и его HTML-вывода.""" + +import logging +from typing import Any, cast + +import pytest + +from cv.models import Profile +from cv.services.dowload.pdf import PdfRenderer +from resume.utils.logging import configure_root_logger + +logger = logging.getLogger(__name__) +configure_root_logger() + + +@pytest.fixture +def profile() -> object: + """Минимальный fake-профиль для тестов. + + Returns: + object: Объект с обязательными атрибутами. + """ + + class P: + def __init__(self) -> None: + self.full_name = "Jane Doe" + self.experience = type("Q", (), {"all": lambda self: []})() + self.skills_map = type("Q", (), {"all": lambda self: []})() + + return P() + + +def test_render_success(monkeypatch: pytest.MonkeyPatch, profile: object) -> None: + """Юнит: валидируем HTML и что write_pdf вызван ровно один раз.""" + logger.info("Проверяем успешный рендер PDF (валидация HTML)") + + fake_serialized = { + "full_name": "Jane Doe", + "role": "Dev", + "summary": "Summary", + "location": "Earth", + "languages": ["EN"], + "contacts": {"email": "jane@example.com", "phone": "", "telegram": ""}, + "experience": [], + "skills_map": [], + } + + def fake_serialize(_self: object, _p: object) -> dict: + return fake_serialized + + captured: dict[str, Any] = {"html": "", "calls": 0} + + class RecordingHTML: + def __init__(self, string: str) -> None: + captured["html"] = string + + def write_pdf(self) -> bytes: + captured["calls"] += 1 + return b"%PDF-FAKE" + + monkeypatch.setattr("cv.services.dowload.pdf.ProfileSerializer.serialize", fake_serialize) + monkeypatch.setattr("cv.services.dowload.pdf.HTML", RecordingHTML) + + out = PdfRenderer().render(cast(Profile, profile)) + + assert captured["calls"] == 1 + assert isinstance(out.getvalue(), bytes) + + html: str = captured["html"] + assert "" in html + assert "Jane Doe — Резюме (PDF)" in html + assert "

Jane Doe

" in html + assert "

Контакты

" in html + assert "

Опыт работы

" in html + assert "

Навыки

" in html + assert "@page { size: A4;" in html + + +def test_render_missing_full_name_raises(monkeypatch: pytest.MonkeyPatch, profile: object) -> None: + """Должен бросать ValueError при пустом full_name. + + Args: + monkeypatch (pytest.MonkeyPatch): Инструмент подмены атрибутов. + profile (object): Фейковый профиль. + """ + logger.info("Проверяем ошибку при отсутствии full_name") + + monkeypatch.setattr( + "cv.services.dowload.pdf.ProfileSerializer.serialize", + lambda _self, _p: {"full_name": ""}, + ) + with pytest.raises(ValueError): + PdfRenderer().render(cast(Profile, profile)) diff --git a/cv/tests/test_views.py b/cv/tests/test_views.py new file mode 100644 index 0000000..6e486f5 --- /dev/null +++ b/cv/tests/test_views.py @@ -0,0 +1,68 @@ +"""Интеграционные тесты Django views приложения `cv`.""" + +import logging +from io import BytesIO + +import pytest +from django.test import Client +from django.urls import reverse + +from cv.models import Profile +from resume.utils.logging import configure_root_logger + +logger = logging.getLogger(__name__) +configure_root_logger() + + +@pytest.mark.django_db +def test_profile_view_context(client: Client) -> None: + """Контекст содержит profile при наличии записи. + + Args: + client (Client): Django тестовый клиент. + """ + logger.info("Создаём профиль и проверяем контекст главной страницы") + Profile.objects.create( + full_name="John Tester", role="QA", gender="male", summary="", location="", languages=[] + ) + resp = client.get("/") + assert resp.status_code == 200 + assert "profile" in resp.context + + +@pytest.mark.django_db +def test_download_pdf_ok(client: Client, monkeypatch: pytest.MonkeyPatch) -> None: + """Выдача PDF c корректными заголовками. + + Args: + client (Client): Django тестовый клиент. + monkeypatch (pytest.MonkeyPatch): Подмена рендера PDF. + """ + logger.info("Проверяем скачивание PDF с реальным роутом") + Profile.objects.create( + full_name="John Tester", role="QA", gender="male", summary="", location="", languages=[] + ) + monkeypatch.setattr("cv.views.PdfRenderer.render", lambda _self, _p: BytesIO(b"%PDF")) + resp = client.get(reverse("cv:resume-pdf")) + assert resp.status_code == 200 + assert resp["Content-Type"] == "application/pdf" + assert resp["Cache-Control"] == "no-store" + assert "resume_John_Tester.pdf" in resp["Content-Disposition"] + + +@pytest.mark.django_db +def test_download_docx_ok(client: Client) -> None: + """Smoke: скачивание DOCX с корректными заголовками.""" + logger.info("Проверяем скачивание DOCX") + Profile.objects.create( + full_name="John Tester", role="QA", gender="male", summary="", location="", languages=[] + ) + resp = client.get(reverse("cv:resume-docx")) + assert resp.status_code == 200 + assert ( + resp["Content-Type"] + == "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ) + assert resp["Cache-Control"] == "no-store" + assert "attachment; filename=" in resp["Content-Disposition"] + assert "resume_John_Tester.docx" in resp["Content-Disposition"] diff --git a/poetry.lock b/poetry.lock index 2b68276..03f98be 100644 --- a/poetry.lock +++ b/poetry.lock @@ -498,7 +498,7 @@ description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["dev"] -markers = "platform_system == \"Windows\"" +markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -826,6 +826,18 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -1653,6 +1665,22 @@ docs = ["furo (>=2025.9.25)", "proselint (>=0.14)", "sphinx (>=8.2.3)", "sphinx- test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.4.2)", "pytest-cov (>=7)", "pytest-mock (>=3.15.1)"] type = ["mypy (>=1.18.2)"] +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + [[package]] name = "pre-commit" version = "4.4.0" @@ -1762,6 +1790,47 @@ files = [ doc = ["sphinx", "sphinx_rtd_theme"] test = ["pytest", "ruff"] +[[package]] +name = "pytest" +version = "9.0.1" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad"}, + {file = "pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-django" +version = "4.11.1" +description = "A Django plugin for pytest." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10"}, + {file = "pytest_django-4.11.1.tar.gz", hash = "sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991"}, +] + +[package.dependencies] +pytest = ">=7.0.0" + +[package.extras] +docs = ["sphinx", "sphinx_rtd_theme"] +testing = ["Django", "django-configurations (>=2.0)"] + [[package]] name = "python-docx" version = "1.2.0" @@ -2250,4 +2319,4 @@ test = ["pytest"] [metadata] lock-version = "2.1" python-versions = ">=3.13,<4.0" -content-hash = "8cf5e7e922f3b5759df281cf8a0539a5f23ccd2fee5bf05226a1e9a6232b17ec" +content-hash = "c6276a47751741808773e773764e5dfd9591dcf0b93b545bbc941fc0d07dee1d" diff --git a/pyproject.toml b/pyproject.toml index 3816031..c005624 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,8 @@ vulture = "^2.14" pre-commit = "^4.4.0" bandit = "^1.7.9" pip-audit = "^2.7.3" +pytest = "^9.0.1" +pytest-django = "^4.11.1" [tool.ruff] line-length = 100 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..bb049d2 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +DJANGO_SETTINGS_MODULE = resume.settings +python_files = tests.py test_*.py *_tests.py \ No newline at end of file diff --git a/resume/tests/__init__.py b/resume/tests/__init__.py new file mode 100644 index 0000000..3923c6b --- /dev/null +++ b/resume/tests/__init__.py @@ -0,0 +1 @@ +"""Тестовый пакет проекта `resume`.""" diff --git a/resume/tests/test_media_tag.py b/resume/tests/test_media_tag.py new file mode 100644 index 0000000..80b124e --- /dev/null +++ b/resume/tests/test_media_tag.py @@ -0,0 +1,39 @@ +"""Тесты для template-тега `media` проекта `resume`.""" + +import base64 +import logging +from pathlib import Path + +from django.test import SimpleTestCase, override_settings + +from resume.tags.media import media +from resume.utils.logging import configure_root_logger + +logger = logging.getLogger(__name__) +configure_root_logger() + + +@override_settings(MEDIA_ROOT="/tmp/resume_media_test") +class TestMediaTemplateTag(SimpleTestCase): + """Тесты для template-тега media.""" + + def setUp(self) -> None: + """Создать MEDIA_ROOT перед тестами.""" + Path("/tmp/resume_media_test").mkdir(parents=True, exist_ok=True) + + def test_media_returns_empty_for_missing(self) -> None: + """Пустая строка, если файла нет.""" + logger.info("Проверяем поведение для отсутствующего файла") + self.assertEqual(media("no_such_file.png"), "") + + def test_media_returns_data_uri_for_existing_file(self) -> None: + """Корректный data URI для реального файла.""" + logger.info("Проверяем формирование data URI") + path = Path("/tmp/resume_media_test/test.txt") + content = b"hello" + path.write_bytes(content) + + uri = media("test.txt") + self.assertTrue(uri.startswith("data:text/plain;base64,")) + b64 = uri.split(",", 1)[1] + self.assertEqual(base64.b64decode(b64), content)