mirror of
https://github.com/kevinveenbirkenbach/homepage.veen.world.git
synced 2026-04-07 05:12:19 +00:00
feat: migrate to pyproject.toml, add test suites, split CI workflows
- Replace requirements.txt with pyproject.toml for modern Python packaging - Add unit, integration, lint and security test suites under tests/ - Add utils/export_runtime_requirements.py and utils/check_hadolint_sarif.py - Split monolithic CI into reusable lint.yml, security.yml and tests.yml - Refactor ci.yml to orchestrate reusable workflows; publish on semver tag only - Modernize Dockerfile: pin python:3.12-slim, install via pyproject.toml - Expand Makefile with lint, security, test and CI targets - Add test-e2e via act with portfolio container stop/start around run - Fix navbar_logo_visibility.spec.js: win.fullscreen() → win.enterFullscreen() - Set use_reloader=False in app.run() to prevent double-start in CI - Add app/core.* and build artifacts to .gitignore - Fix apt-get → sudo apt-get in tests.yml e2e job - Fix pip install --ignore-installed to handle stale act cache Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1
tests/unit/__init__.py
Normal file
1
tests/unit/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Unit test package for Portfolio UI."""
|
||||
72
tests/unit/test_cache_manager.py
Normal file
72
tests/unit/test_cache_manager.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import requests
|
||||
|
||||
from app.utils.cache_manager import CacheManager
|
||||
|
||||
|
||||
class TestCacheManager(unittest.TestCase):
|
||||
def test_init_creates_cache_directory(self):
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
cache_dir = Path(temp_dir) / "cache"
|
||||
|
||||
self.assertFalse(cache_dir.exists())
|
||||
|
||||
CacheManager(str(cache_dir))
|
||||
|
||||
self.assertTrue(cache_dir.is_dir())
|
||||
|
||||
def test_clear_cache_removes_files_but_keeps_subdirectories(self):
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
cache_dir = Path(temp_dir) / "cache"
|
||||
nested_dir = cache_dir / "nested"
|
||||
nested_dir.mkdir(parents=True)
|
||||
file_path = cache_dir / "icon.png"
|
||||
file_path.write_bytes(b"icon")
|
||||
|
||||
manager = CacheManager(str(cache_dir))
|
||||
manager.clear_cache()
|
||||
|
||||
self.assertFalse(file_path.exists())
|
||||
self.assertTrue(nested_dir.is_dir())
|
||||
|
||||
@patch("app.utils.cache_manager.requests.get")
|
||||
def test_cache_file_downloads_and_stores_response(self, mock_get):
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
manager = CacheManager(str(Path(temp_dir) / "cache"))
|
||||
response = Mock()
|
||||
response.headers = {"Content-Type": "image/svg+xml; charset=utf-8"}
|
||||
response.iter_content.return_value = [b"<svg>ok</svg>"]
|
||||
response.raise_for_status.return_value = None
|
||||
mock_get.return_value = response
|
||||
|
||||
cached_path = manager.cache_file("https://example.com/logo/download")
|
||||
|
||||
self.assertIsNotNone(cached_path)
|
||||
self.assertTrue(cached_path.startswith("cache/logo_"))
|
||||
self.assertTrue(cached_path.endswith(".svg"))
|
||||
|
||||
stored_file = Path(manager.cache_dir) / Path(cached_path).name
|
||||
self.assertEqual(stored_file.read_bytes(), b"<svg>ok</svg>")
|
||||
mock_get.assert_called_once_with(
|
||||
"https://example.com/logo/download",
|
||||
stream=True,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
@patch("app.utils.cache_manager.requests.get")
|
||||
def test_cache_file_returns_none_when_request_fails(self, mock_get):
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
manager = CacheManager(str(Path(temp_dir) / "cache"))
|
||||
mock_get.side_effect = requests.RequestException("network")
|
||||
|
||||
cached_path = manager.cache_file("https://example.com/icon.png")
|
||||
|
||||
self.assertIsNone(cached_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
49
tests/unit/test_check_hadolint_sarif.py
Normal file
49
tests/unit/test_check_hadolint_sarif.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from utils import check_hadolint_sarif
|
||||
|
||||
|
||||
class TestCheckHadolintSarif(unittest.TestCase):
|
||||
def test_main_returns_zero_for_clean_sarif(self):
|
||||
sarif_payload = {
|
||||
"runs": [
|
||||
{
|
||||
"results": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
sarif_path = Path(temp_dir) / "clean.sarif"
|
||||
sarif_path.write_text(json.dumps(sarif_payload), encoding="utf-8")
|
||||
|
||||
exit_code = check_hadolint_sarif.main([str(sarif_path)])
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
|
||||
def test_main_returns_one_for_warnings_or_errors(self):
|
||||
sarif_payload = {
|
||||
"runs": [
|
||||
{
|
||||
"results": [
|
||||
{"level": "warning"},
|
||||
{"level": "error"},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
sarif_path = Path(temp_dir) / "warnings.sarif"
|
||||
sarif_path.write_text(json.dumps(sarif_payload), encoding="utf-8")
|
||||
|
||||
exit_code = check_hadolint_sarif.main([str(sarif_path)])
|
||||
|
||||
self.assertEqual(exit_code, 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
39
tests/unit/test_compute_card_classes.py
Normal file
39
tests/unit/test_compute_card_classes.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import unittest
|
||||
|
||||
from app.utils.compute_card_classes import compute_card_classes
|
||||
|
||||
|
||||
class TestComputeCardClasses(unittest.TestCase):
|
||||
def test_single_card_uses_full_width_classes(self):
|
||||
lg_classes, md_classes = compute_card_classes([{"title": "One"}])
|
||||
|
||||
self.assertEqual(lg_classes, ["col-lg-12"])
|
||||
self.assertEqual(md_classes, ["col-md-12"])
|
||||
|
||||
def test_two_cards_split_evenly(self):
|
||||
lg_classes, md_classes = compute_card_classes([{}, {}])
|
||||
|
||||
self.assertEqual(lg_classes, ["col-lg-6", "col-lg-6"])
|
||||
self.assertEqual(md_classes, ["col-md-6", "col-md-6"])
|
||||
|
||||
def test_three_cards_use_thirds(self):
|
||||
lg_classes, md_classes = compute_card_classes([{}, {}, {}])
|
||||
|
||||
self.assertEqual(lg_classes, ["col-lg-4", "col-lg-4", "col-lg-4"])
|
||||
self.assertEqual(md_classes, ["col-md-6", "col-md-6", "col-md-12"])
|
||||
|
||||
def test_five_cards_use_balanced_large_layout(self):
|
||||
lg_classes, md_classes = compute_card_classes([{}, {}, {}, {}, {}])
|
||||
|
||||
self.assertEqual(
|
||||
lg_classes,
|
||||
["col-lg-6", "col-lg-6", "col-lg-4", "col-lg-4", "col-lg-4"],
|
||||
)
|
||||
self.assertEqual(
|
||||
md_classes,
|
||||
["col-md-6", "col-md-6", "col-md-6", "col-md-6", "col-md-12"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
74
tests/unit/test_configuration_resolver.py
Normal file
74
tests/unit/test_configuration_resolver.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import unittest
|
||||
|
||||
from app.utils.configuration_resolver import ConfigurationResolver
|
||||
|
||||
|
||||
class TestConfigurationResolver(unittest.TestCase):
|
||||
def test_resolve_links_replaces_mapping_link_with_target_object(self):
|
||||
config = {
|
||||
"profiles": [
|
||||
{"name": "Mastodon", "url": "https://example.com/@user"},
|
||||
],
|
||||
"featured": {"link": "profiles.mastodon"},
|
||||
}
|
||||
|
||||
resolver = ConfigurationResolver(config)
|
||||
resolver.resolve_links()
|
||||
|
||||
self.assertEqual(
|
||||
resolver.get_config()["featured"],
|
||||
{"name": "Mastodon", "url": "https://example.com/@user"},
|
||||
)
|
||||
|
||||
def test_resolve_links_expands_children_link_to_list_entries(self):
|
||||
config = {
|
||||
"accounts": {
|
||||
"children": [
|
||||
{"name": "Matrix", "url": "https://matrix.example"},
|
||||
{"name": "Signal", "url": "https://signal.example"},
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"children": [
|
||||
{"link": "accounts.children"},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
resolver = ConfigurationResolver(config)
|
||||
resolver.resolve_links()
|
||||
|
||||
self.assertEqual(
|
||||
resolver.get_config()["navigation"]["children"],
|
||||
[
|
||||
{"name": "Matrix", "url": "https://matrix.example"},
|
||||
{"name": "Signal", "url": "https://signal.example"},
|
||||
],
|
||||
)
|
||||
|
||||
def test_resolve_links_rejects_non_list_children(self):
|
||||
config = {"navigation": {"children": {"name": "Invalid"}}}
|
||||
|
||||
resolver = ConfigurationResolver(config)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
resolver.resolve_links()
|
||||
|
||||
def test_find_entry_handles_case_and_space_insensitive_paths(self):
|
||||
config = {
|
||||
"Social Networks": {
|
||||
"children": [
|
||||
{"name": "Friendica", "url": "https://friendica.example"},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
resolver = ConfigurationResolver(config)
|
||||
|
||||
entry = resolver._find_entry(config, "socialnetworks.friendica", False)
|
||||
|
||||
self.assertEqual(entry["url"], "https://friendica.example")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
45
tests/unit/test_export_runtime_requirements.py
Normal file
45
tests/unit/test_export_runtime_requirements.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
from utils import export_runtime_requirements
|
||||
|
||||
|
||||
class TestExportRuntimeRequirements(unittest.TestCase):
|
||||
def test_load_runtime_requirements_reads_project_dependencies(self):
|
||||
pyproject_content = """
|
||||
[project]
|
||||
dependencies = [
|
||||
"flask",
|
||||
"requests>=2",
|
||||
]
|
||||
""".lstrip()
|
||||
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
pyproject_path = Path(temp_dir) / "pyproject.toml"
|
||||
pyproject_path.write_text(pyproject_content, encoding="utf-8")
|
||||
|
||||
requirements = export_runtime_requirements.load_runtime_requirements(
|
||||
pyproject_path
|
||||
)
|
||||
|
||||
self.assertEqual(requirements, ["flask", "requests>=2"])
|
||||
|
||||
def test_main_prints_requirements_from_selected_pyproject(self):
|
||||
pyproject_content = """
|
||||
[project]
|
||||
dependencies = [
|
||||
"pyyaml",
|
||||
]
|
||||
""".lstrip()
|
||||
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
pyproject_path = Path(temp_dir) / "pyproject.toml"
|
||||
pyproject_path.write_text(pyproject_content, encoding="utf-8")
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
exit_code = export_runtime_requirements.main([str(pyproject_path)])
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
mock_print.assert_called_once_with("pyyaml")
|
||||
72
tests/unit/test_main.py
Normal file
72
tests/unit/test_main.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
import main as portfolio_main
|
||||
|
||||
|
||||
class TestMainCli(unittest.TestCase):
|
||||
def test_load_targets_parses_help_comments(self):
|
||||
makefile_content = """
|
||||
.PHONY: foo bar
|
||||
foo:
|
||||
\t# Run foo
|
||||
\t@echo foo
|
||||
|
||||
bar:
|
||||
\t@echo bar
|
||||
""".lstrip()
|
||||
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
makefile_path = Path(temp_dir) / "Makefile"
|
||||
makefile_path.write_text(makefile_content, encoding="utf-8")
|
||||
|
||||
targets = portfolio_main.load_targets(makefile_path)
|
||||
|
||||
self.assertEqual(targets, [("foo", "Run foo"), ("bar", "")])
|
||||
|
||||
@patch("main.subprocess.check_call")
|
||||
def test_run_command_executes_subprocess(self, mock_check_call):
|
||||
portfolio_main.run_command(["make", "lint"])
|
||||
|
||||
mock_check_call.assert_called_once_with(["make", "lint"])
|
||||
|
||||
@patch("main.sys.exit", side_effect=SystemExit(7))
|
||||
@patch(
|
||||
"main.subprocess.check_call",
|
||||
side_effect=subprocess.CalledProcessError(7, ["make", "lint"]),
|
||||
)
|
||||
def test_run_command_exits_with_subprocess_return_code(
|
||||
self,
|
||||
_mock_check_call,
|
||||
mock_sys_exit,
|
||||
):
|
||||
with self.assertRaises(SystemExit) as context:
|
||||
portfolio_main.run_command(["make", "lint"])
|
||||
|
||||
self.assertEqual(context.exception.code, 7)
|
||||
mock_sys_exit.assert_called_once_with(7)
|
||||
|
||||
@patch("main.run_command")
|
||||
@patch("main.load_targets", return_value=[("lint", "Run lint suite")])
|
||||
def test_main_dispatches_selected_target(
|
||||
self, _mock_load_targets, mock_run_command
|
||||
):
|
||||
with patch("sys.argv", ["main.py", "lint"]):
|
||||
portfolio_main.main()
|
||||
|
||||
mock_run_command.assert_called_once_with(["make", "lint"], dry_run=False)
|
||||
|
||||
@patch("main.run_command")
|
||||
@patch("main.load_targets", return_value=[("lint", "Run lint suite")])
|
||||
def test_main_passes_dry_run_flag(self, _mock_load_targets, mock_run_command):
|
||||
with patch("sys.argv", ["main.py", "--dry-run", "lint"]):
|
||||
portfolio_main.main()
|
||||
|
||||
mock_run_command.assert_called_once_with(["make", "lint"], dry_run=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user