From da2e657416fbf0181f8882b35241f7e901d8d261 Mon Sep 17 00:00:00 2001 From: Sina Atalay <79940989+sinaatalay@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:39:23 +0300 Subject: [PATCH] Increase test coverage --- tests/cli/render_command/test_run_rendercv.py | 47 ++++++++++ tests/cli/render_command/test_watcher.py | 31 +++++++ tests/cli/test_app.py | 78 +++++++++++++++- tests/renderer/templater/test_connections.py | 20 +++++ .../test_entry_templates_from_input.py | 90 +++++++++++++++++++ .../templater/test_model_processor.py | 90 ++++++++++++++++++- tests/renderer/test_pdf_png.py | 31 +++++++ tests/schema/models/cv/test_cv.py | 9 ++ tests/schema/models/design/test_design.py | 55 ++++++++++++ tests/schema/test_pydantic_error_handling.py | 22 +++++ tests/schema/test_rendercv_model_builder.py | 10 +++ tests/schema/test_yaml_reader.py | 6 ++ 12 files changed, 487 insertions(+), 2 deletions(-) diff --git a/tests/cli/render_command/test_run_rendercv.py b/tests/cli/render_command/test_run_rendercv.py index ccbbdff4..056820d6 100644 --- a/tests/cli/render_command/test_run_rendercv.py +++ b/tests/cli/render_command/test_run_rendercv.py @@ -1,6 +1,7 @@ import os import pathlib import sys +from unittest.mock import patch import pytest import typer @@ -11,6 +12,7 @@ from rendercv.cli.render_command.run_rendercv import ( run_rendercv, timed_step, ) +from rendercv.exception import RenderCVUserError class TestTimedStep: @@ -162,6 +164,24 @@ design: # Restore permissions for cleanup yaml_file.chmod(original_mode) + def test_user_error_during_rendering(self, tmp_path): + yaml_file = tmp_path / "test.yaml" + yaml_file.write_text("cv:\n name: John Doe\n", encoding="utf-8") + progress = ProgressPanel(quiet=True) + + with ( + patch( + "rendercv.cli.render_command.run_rendercv" + ".build_rendercv_dictionary_and_model", + side_effect=RenderCVUserError(message="test error"), + ), + pytest.raises(typer.Exit) as exc_info, + progress, + ): + run_rendercv(yaml_file, progress) + + assert exc_info.value.exit_code == 1 + class TestCollectInputFilePaths: def test_returns_only_input_file_by_default(self, tmp_path): @@ -212,6 +232,33 @@ class TestCollectInputFilePaths: assert result == {"input": yaml_file} + def test_includes_cli_provided_locale_file(self, tmp_path): + yaml_file = tmp_path / "cv.yaml" + yaml_file.write_text("cv:\n name: John Doe\n", encoding="utf-8") + locale_file = tmp_path / "locale.yaml" + locale_file.touch() + + result = collect_input_file_paths(yaml_file, locale=locale_file) + + assert result["input"] == yaml_file + assert result["locale"] == locale_file + + def test_includes_yaml_referenced_locale_file(self, tmp_path): + locale_file = tmp_path / "my_locale.yaml" + locale_file.touch() + + yaml_file = tmp_path / "cv.yaml" + yaml_file.write_text( + "cv:\n name: John Doe\n" + "settings:\n render_command:\n locale: my_locale.yaml\n", + encoding="utf-8", + ) + + result = collect_input_file_paths(yaml_file) + + assert result["input"] == yaml_file + assert result["locale"] == locale_file.resolve() + def test_cli_flags_take_precedence_over_yaml_references(self, tmp_path): yaml_ref_design = tmp_path / "yaml_design.yaml" yaml_ref_design.touch() diff --git a/tests/cli/render_command/test_watcher.py b/tests/cli/render_command/test_watcher.py index 954dfe05..7442fa64 100644 --- a/tests/cli/render_command/test_watcher.py +++ b/tests/cli/render_command/test_watcher.py @@ -3,8 +3,10 @@ import time from unittest.mock import MagicMock, patch import typer +import watchdog.events from rendercv.cli.render_command import watcher +from rendercv.cli.render_command.watcher import EventHandler class TestRunFunctionIfFilesChange: @@ -108,3 +110,32 @@ class TestRunFunctionIfFilesChange: time.sleep(0.2) assert call_count > count_after_exit + + +class TestEventHandler: + def test_ignores_events_for_unwatched_files(self): + mock_fn = MagicMock() + handler = EventHandler(mock_fn, watched_files={"/watched/file.yaml"}) + + event = watchdog.events.FileModifiedEvent("/other/file.yaml") + handler.on_modified(event) + + mock_fn.assert_not_called() + + def test_calls_function_for_watched_file(self): + mock_fn = MagicMock() + handler = EventHandler(mock_fn, watched_files={"/watched/file.yaml"}) + + event = watchdog.events.FileModifiedEvent("/watched/file.yaml") + handler.on_modified(event) + + mock_fn.assert_called_once() + + def test_suppresses_typer_exit(self): + mock_fn = MagicMock(side_effect=typer.Exit(code=1)) + handler = EventHandler(mock_fn, watched_files={"/watched/file.yaml"}) + + event = watchdog.events.FileModifiedEvent("/watched/file.yaml") + handler.on_modified(event) + + mock_fn.assert_called_once() diff --git a/tests/cli/test_app.py b/tests/cli/test_app.py index d6f17525..6dea1d15 100644 --- a/tests/cli/test_app.py +++ b/tests/cli/test_app.py @@ -2,7 +2,7 @@ import json import pathlib import sys import time -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from typer.testing import CliRunner @@ -11,7 +11,10 @@ from rendercv import __version__ from rendercv.cli.app import ( VERSION_CHECK_TTL_SECONDS, app, + fetch_and_cache_latest_version, + fetch_latest_version_from_pypi, get_cache_dir, + get_version_cache_file, read_version_cache, warn_if_new_version_is_available, write_version_cache, @@ -75,6 +78,13 @@ class TestGetCacheDir: assert get_cache_dir() == tmp_path / "rendercv" +def test_get_version_cache_file(): + result = get_version_cache_file() + + assert result.name == "version_check.json" + assert result.parent == get_cache_dir() + + class TestReadVersionCache: def test_returns_none_for_missing_file(self, tmp_path, monkeypatch): monkeypatch.setattr( @@ -133,6 +143,60 @@ class TestWriteVersionCache: assert data["latest_version"] == "2.0.0" assert "last_check" in data + def test_silently_ignores_os_error(self, monkeypatch): + monkeypatch.setattr( + "rendercv.cli.app.get_version_cache_file", + lambda: pathlib.Path("/nonexistent/readonly/path/version_check.json"), + ) + + write_version_cache("2.0.0") + + +class TestFetchLatestVersionFromPypi: + def test_returns_version_on_success(self, monkeypatch): + response_data = json.dumps({"info": {"version": "3.0.0"}}).encode() + mock_response = MagicMock() + mock_response.read.return_value = response_data + mock_response.info.return_value.get_content_charset.return_value = "utf-8" + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + + monkeypatch.setattr( + "rendercv.cli.app.urllib.request.urlopen", + lambda *a, **kw: mock_response, + ) + + result = fetch_latest_version_from_pypi() + + assert result == "3.0.0" + + @patch( + "rendercv.cli.app.urllib.request.urlopen", + side_effect=ConnectionError("fail"), + ) + def test_returns_none_on_failure(self, mock_urlopen): # NOQA: ARG002 + result = fetch_latest_version_from_pypi() + + assert result is None + + +class TestFetchAndCacheLatestVersion: + @patch("rendercv.cli.app.write_version_cache") + @patch("rendercv.cli.app.fetch_latest_version_from_pypi", return_value="3.0.0") + def test_fetches_and_writes_cache(self, mock_fetch, mock_write): + fetch_and_cache_latest_version() + + mock_fetch.assert_called_once() + mock_write.assert_called_once_with("3.0.0") + + @patch("rendercv.cli.app.write_version_cache") + @patch("rendercv.cli.app.fetch_latest_version_from_pypi", return_value=None) + def test_does_not_write_cache_on_fetch_failure(self, mock_fetch, mock_write): + fetch_and_cache_latest_version() + + mock_fetch.assert_called_once() + mock_write.assert_not_called() + def write_cache(tmp_path, version, age_seconds=0): """Helper to write a version cache file for testing.""" @@ -240,3 +304,15 @@ class TestWarnIfNewVersionIsAvailable: data = json.loads(cache_file.read_text(encoding="utf-8")) assert data["latest_version"] == "99.0.0" + + def test_handles_invalid_version_in_cache(self, tmp_path, capsys, monkeypatch): + write_cache(tmp_path, "not.a.version", age_seconds=0) + monkeypatch.setattr( + "rendercv.cli.app.get_version_cache_file", + lambda: tmp_path / "version_check.json", + ) + + warn_if_new_version_is_available() + + captured = capsys.readouterr() + assert "new version" not in captured.out.lower() diff --git a/tests/renderer/templater/test_connections.py b/tests/renderer/templater/test_connections.py index 8a489102..a327f885 100644 --- a/tests/renderer/templater/test_connections.py +++ b/tests/renderer/templater/test_connections.py @@ -3,6 +3,7 @@ from typing import Literal, get_args import pydantic import pytest +from rendercv.exception import RenderCVInternalError from rendercv.renderer.templater.connections import ( compute_connections, compute_connections_for_markdown, @@ -417,3 +418,22 @@ class TestIconMapping: assert conn_type in fontawesome_icons, ( f"Missing icon for connection type: {conn_type}" ) + + +class TestParseConnectionsInternalErrors: + """Test defensive guards when _key_order contains a key but the field is None.""" + + def _make_model_with_none_field(self, key: str) -> RenderCVModel: + cv = Cv.model_validate({"name": "John Doe"}) + cv._key_order = [key] + return create_rendercv_model(cv) + + @pytest.mark.parametrize( + "key", + ["phone", "website", "social_networks", "custom_connections"], + ) + def test_raises_for_none_field_in_key_order(self, key): + model = self._make_model_with_none_field(key) + + with pytest.raises(RenderCVInternalError): + parse_connections(model) diff --git a/tests/renderer/templater/test_entry_templates_from_input.py b/tests/renderer/templater/test_entry_templates_from_input.py index 67012489..27527ea8 100644 --- a/tests/renderer/templater/test_entry_templates_from_input.py +++ b/tests/renderer/templater/test_entry_templates_from_input.py @@ -1,4 +1,5 @@ from datetime import date as Date +from unittest.mock import patch import pydantic import pytest @@ -489,6 +490,95 @@ def test_remove_not_provided_placeholders(entry_templates, entry_fields, expecte assert result == expected +class TestRenderEntryTemplatesInternalErrors: + """Test defensive guards when model_dump includes a key but the attribute is None.""" + + @pytest.mark.parametrize( + ("entry_type", "field_name"), + [ + ("education", "highlights"), + ("publication", "authors"), + ], + ) + def test_raises_when_field_in_dump_but_attribute_is_none( + self, entry_type, field_name + ): + if entry_type == "education": + entry = EducationEntry.model_validate( + { + "institution": "MIT", + "area": "CS", + "highlights": ["A"], + "start_date": "2020-01", + "end_date": "2021-01", + } + ) + else: + entry = PublicationEntry.model_validate( + { + "title": "Paper", + "authors": ["John"], + "date": "2024-01", + } + ) + + # Set the attribute to None while model_dump still includes it + original_model_dump = entry.model_dump + + def patched_model_dump(**kwargs): + result = original_model_dump(**kwargs) + result[field_name] = "placeholder" + return result + + entry.model_dump = patched_model_dump # ty: ignore[invalid-assignment] + setattr(entry, field_name, None) + + with pytest.raises(RenderCVInternalError): + render_entry_templates( + entry, + templates=Templates(), + locale=EnglishLocale(), + show_time_span=False, + current_date=Date(2024, 1, 1), + ) + + @pytest.mark.parametrize("field_name", ["start_date", "end_date"]) + def test_raises_when_date_field_in_dump_but_attribute_is_none(self, field_name): + entry = EducationEntry.model_validate( + { + "institution": "MIT", + "area": "CS", + "start_date": "2020-01", + "end_date": "2021-01", + } + ) + + original_model_dump = entry.model_dump + + def patched_model_dump(**kwargs): + result = original_model_dump(**kwargs) + result[field_name] = "placeholder" + return result + + entry.model_dump = patched_model_dump # ty: ignore[invalid-assignment] + setattr(entry, field_name, None) + + with ( + patch( + "rendercv.renderer.templater.entry_templates_from_input.process_date", + return_value="Jan 2020 – Jan 2021", + ), + pytest.raises(RenderCVInternalError), + ): + render_entry_templates( + entry, + templates=Templates(), + locale=EnglishLocale(), + show_time_span=False, + current_date=Date(2024, 1, 1), + ) + + @pytest.mark.parametrize( ("input_text", "expected"), [ diff --git a/tests/renderer/templater/test_model_processor.py b/tests/renderer/templater/test_model_processor.py index 4b437d68..ebd8d74e 100644 --- a/tests/renderer/templater/test_model_processor.py +++ b/tests/renderer/templater/test_model_processor.py @@ -1,9 +1,15 @@ from datetime import date as Date +from unittest.mock import patch import pydantic import pytest -from rendercv.renderer.templater.model_processor import process_fields, process_model +from rendercv.exception import RenderCVUserError +from rendercv.renderer.templater.model_processor import ( + download_photo_from_url, + process_fields, + process_model, +) from rendercv.schema.models.cv.cv import Cv from rendercv.schema.models.cv.entries.normal import NormalEntry from rendercv.schema.models.rendercv_model import RenderCVModel @@ -193,3 +199,85 @@ class TestProcessModel: result = process_model(rendercv_model, "typst") assert result.settings.pdf_title == "John Doe - Resume 2024" + + +class TestDownloadPhotoFromUrl: + def test_skips_when_photo_is_none(self): + cv = Cv.model_validate({"name": "John Doe"}) + model = RenderCVModel(cv=cv) + + download_photo_from_url(model) + + assert model.cv.photo is None + + def test_skips_when_photo_is_local_path(self, tmp_path): + cv = Cv.model_validate({"name": "John Doe"}) + model = RenderCVModel(cv=cv) + model.cv.photo = tmp_path / "photo.jpg" + + download_photo_from_url(model) + + assert model.cv.photo == tmp_path / "photo.jpg" + + def test_downloads_photo_from_url(self, tmp_path): + cv = Cv.model_validate({"name": "John Doe"}) + model = RenderCVModel(cv=cv) + model.cv.photo = pydantic.HttpUrl("https://example.com/photo.jpg") + model.settings.render_command.output_folder = tmp_path / "output" + + with patch( + "rendercv.renderer.templater.model_processor.urllib.request.urlretrieve" + ) as mock_retrieve: + download_photo_from_url(model) + + mock_retrieve.assert_called_once() + assert model.cv.photo == tmp_path / "output" / "photo.jpg" + + def test_uses_photo_jpg_fallback_when_no_filename_in_url(self, tmp_path): + cv = Cv.model_validate({"name": "John Doe"}) + model = RenderCVModel(cv=cv) + model.cv.photo = pydantic.HttpUrl("https://example.com/") + model.settings.render_command.output_folder = tmp_path / "output" + + with patch( + "rendercv.renderer.templater.model_processor.urllib.request.urlretrieve" + ): + download_photo_from_url(model) + + assert model.cv.photo == tmp_path / "output" / "photo.jpg" + + def test_skips_download_when_file_already_exists(self, tmp_path): + cv = Cv.model_validate({"name": "John Doe"}) + model = RenderCVModel(cv=cv) + model.cv.photo = pydantic.HttpUrl("https://example.com/photo.jpg") + output_dir = tmp_path / "output" + output_dir.mkdir() + (output_dir / "photo.jpg").write_bytes(b"existing") + model.settings.render_command.output_folder = output_dir + + with patch( + "rendercv.renderer.templater.model_processor.urllib.request.urlretrieve" + ) as mock_retrieve: + download_photo_from_url(model) + + mock_retrieve.assert_not_called() + assert model.cv.photo == output_dir / "photo.jpg" + + def test_raises_user_error_on_download_failure(self, tmp_path): + cv = Cv.model_validate({"name": "John Doe"}) + model = RenderCVModel(cv=cv) + model.cv.photo = pydantic.HttpUrl("https://example.com/photo.jpg") + model.settings.render_command.output_folder = tmp_path / "output" + + with ( + patch( + "rendercv.renderer.templater.model_processor" + ".urllib.request.urlretrieve", + side_effect=ConnectionError("network error"), + ), + pytest.raises(RenderCVUserError) as exc_info, + ): + download_photo_from_url(model) + + assert exc_info.value.message is not None + assert "Failed to download photo" in exc_info.value.message diff --git a/tests/renderer/test_pdf_png.py b/tests/renderer/test_pdf_png.py index 725b1d49..2a4e00d6 100644 --- a/tests/renderer/test_pdf_png.py +++ b/tests/renderer/test_pdf_png.py @@ -1,7 +1,9 @@ import pathlib +from unittest.mock import MagicMock, patch import pytest +from rendercv.exception import RenderCVInternalError from rendercv.renderer.pdf_png import generate_pdf, generate_png from rendercv.renderer.typst import generate_typst from rendercv.schema.models.design.built_in_design import available_themes @@ -135,3 +137,32 @@ class TestGeneratePngCleansUpOldFiles: assert result is not None assert len(result) >= 1 + + +def test_raises_error_when_typst_returns_none_bytes( + tmp_path: pathlib.Path, + minimal_rendercv_model: RenderCVModel, +): + model = RenderCVModel( + cv=minimal_rendercv_model.cv, + design={"theme": "classic"}, + locale=minimal_rendercv_model.locale, + settings=minimal_rendercv_model.settings, + ) + output_dir = tmp_path / "output" + output_dir.mkdir() + + model.settings.render_command.typst_path = output_dir / "John_Doe_CV.typ" + typst_path = generate_typst(model) + model.settings.render_command.png_path = output_dir / "John_Doe_CV.png" + + mock_compiler = MagicMock() + mock_compiler.compile.return_value = [None] + + with ( + patch( + "rendercv.renderer.pdf_png.get_typst_compiler", return_value=mock_compiler + ), + pytest.raises(RenderCVInternalError, match="Typst compiler returned None"), + ): + generate_png(model, typst_path) diff --git a/tests/schema/models/cv/test_cv.py b/tests/schema/models/cv/test_cv.py index fcf9acc7..3042b04d 100644 --- a/tests/schema/models/cv/test_cv.py +++ b/tests/schema/models/cv/test_cv.py @@ -1,8 +1,10 @@ from typing import Any +from unittest.mock import MagicMock import pydantic import pytest +from rendercv.exception import RenderCVInternalError from rendercv.schema.models.cv.cv import Cv from rendercv.schema.models.cv.section import available_entry_type_names @@ -87,3 +89,10 @@ class TestCv: assert "tel:" not in serialized["phone"] assert serialized["phone"] == "+90-541-999-99-99" + + def test_raises_internal_error_when_field_name_is_none(self): + mock_info = MagicMock(spec=pydantic.ValidationInfo) + mock_info.field_name = None + + with pytest.raises(RenderCVInternalError, match="field_name is None"): + Cv.validate_list_or_scalar_fields("test@example.com", mock_info) diff --git a/tests/schema/models/design/test_design.py b/tests/schema/models/design/test_design.py index 1c7bf936..4221467a 100644 --- a/tests/schema/models/design/test_design.py +++ b/tests/schema/models/design/test_design.py @@ -1,9 +1,11 @@ import os import pathlib +from unittest.mock import MagicMock, patch import pydantic import pytest +from rendercv.exception import RenderCVInternalError from rendercv.schema.models.design.design import Design from rendercv.schema.models.validation_context import ValidationContext @@ -140,3 +142,56 @@ class SomeOtherClass(BaseModel): design_adapter.validate_python( {"theme": "classic", "colors": "invalid_value_not_a_dict"} ) + + def test_raises_internal_error_when_spec_is_none(self, design_adapter, tmp_path): + custom_theme_path = tmp_path / "mytheme" + custom_theme_path.mkdir() + (custom_theme_path / "EducationEntry.j2.typ").touch() + (custom_theme_path / "__init__.py").write_text("# empty", encoding="utf-8") + + with ( + patch( + "rendercv.schema.models.design.design.importlib.util" + ".spec_from_file_location", + return_value=None, + ), + pytest.raises(RenderCVInternalError, match="Failed to load spec"), + ): + design_adapter.validate_python( + {"theme": "mytheme"}, + context={ + "context": ValidationContext( + input_file_path=tmp_path / "input.yaml" + ) + }, + ) + + def test_raises_internal_error_when_spec_loader_is_none( + self, design_adapter, tmp_path + ): + custom_theme_path = tmp_path / "mytheme" + custom_theme_path.mkdir() + (custom_theme_path / "EducationEntry.j2.typ").touch() + (custom_theme_path / "__init__.py").write_text("# empty", encoding="utf-8") + + mock_spec = MagicMock() + mock_spec.name = "theme" + mock_spec.loader = None + mock_spec.submodule_search_locations = None + + with ( + patch( + "rendercv.schema.models.design.design.importlib.util" + ".spec_from_file_location", + return_value=mock_spec, + ), + pytest.raises(RenderCVInternalError, match=r"spec\.loader is None"), + ): + design_adapter.validate_python( + {"theme": "mytheme"}, + context={ + "context": ValidationContext( + input_file_path=tmp_path / "input.yaml" + ) + }, + ) diff --git a/tests/schema/test_pydantic_error_handling.py b/tests/schema/test_pydantic_error_handling.py index 81e67af5..a331b5f4 100644 --- a/tests/schema/test_pydantic_error_handling.py +++ b/tests/schema/test_pydantic_error_handling.py @@ -1,9 +1,11 @@ from dataclasses import asdict +from unittest.mock import MagicMock import pydantic import pytest from rendercv.exception import RenderCVInternalError +from rendercv.schema.models.custom_error_types import CustomPydanticErrorTypes from rendercv.schema.models.rendercv_model import RenderCVModel from rendercv.schema.models.validation_context import ValidationContext from rendercv.schema.pydantic_error_handling import ( @@ -184,6 +186,26 @@ class TestParseValidationErrorsWithOverlaySources: assert err.yaml_source == "design_yaml_file" +class TestParseValidationErrorsInternalErrors: + def test_raises_for_entry_validation_error_missing_ctx(self): + mock_exception = MagicMock(spec=pydantic.ValidationError) + mock_exception.errors.return_value = [ + { + "type": CustomPydanticErrorTypes.entry_validation.value, + "loc": ("cv",), + "msg": "entry validation failed", + "input": {}, + } + ] + + input_dict = read_yaml("cv:\n name: John Doe") + + with pytest.raises( + RenderCVInternalError, match="entry_validation error missing" + ): + parse_validation_errors(mock_exception, input_dict) + + class TestGetInnerYamlObjectFromItsKey: def test_returns_object_and_coordinates_for_valid_key(self): yaml_content = "name: John\nage: 30" diff --git a/tests/schema/test_rendercv_model_builder.py b/tests/schema/test_rendercv_model_builder.py index 23f1196c..f6dd5a6a 100644 --- a/tests/schema/test_rendercv_model_builder.py +++ b/tests/schema/test_rendercv_model_builder.py @@ -11,6 +11,7 @@ from rendercv.schema.rendercv_model_builder import ( build_rendercv_dictionary, build_rendercv_dictionary_and_model, build_rendercv_model_from_commented_map, + get_yaml_error_location, ) from rendercv.schema.sample_generator import dictionary_to_yaml @@ -712,3 +713,12 @@ class TestBuildRendercvModel: _, model = build_rendercv_dictionary_and_model(main_yaml, **kwargs) # ty: ignore[invalid-argument-type] assert check(model) + + +class TestGetYamlErrorLocation: + def test_returns_none_when_no_marks(self): + error = ruamel.yaml.YAMLError() + + result = get_yaml_error_location(error) + + assert result is None diff --git a/tests/schema/test_yaml_reader.py b/tests/schema/test_yaml_reader.py index e2c418c6..7f8b2216 100644 --- a/tests/schema/test_yaml_reader.py +++ b/tests/schema/test_yaml_reader.py @@ -36,3 +36,9 @@ class TestReadYaml: with pytest.raises(RenderCVUserError, match="empty"): read_yaml(empty_file_path) + + def test_treats_asterisk_as_plain_text(self): + result = read_yaml("key: *not_an_alias") + + assert isinstance(result, CommentedMap) + assert result["key"] == "*not_an_alias"