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:
2026-03-29 23:03:09 +02:00
parent 2c61da9fc3
commit 252b50d2a7
38 changed files with 1366 additions and 165 deletions

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,54 @@
import tomllib
import unittest
from pathlib import Path
class TestPythonPackaging(unittest.TestCase):
def setUp(self) -> None:
self.repo_root = Path(__file__).resolve().parents[2]
self.pyproject_path = self.repo_root / "pyproject.toml"
with self.pyproject_path.open("rb") as handle:
self.pyproject = tomllib.load(handle)
def test_pyproject_defines_build_system_and_runtime_dependencies(self):
build_system = self.pyproject["build-system"]
project = self.pyproject["project"]
self.assertEqual(build_system["build-backend"], "setuptools.build_meta")
self.assertIn("setuptools>=69", build_system["requires"])
self.assertGreaterEqual(
set(project["dependencies"]),
{"flask", "pyyaml", "requests"},
)
self.assertEqual(project["requires-python"], ">=3.12")
def test_pyproject_defines_dev_dependencies_and_package_contents(self):
project = self.pyproject["project"]
setuptools_config = self.pyproject["tool"]["setuptools"]
package_find = setuptools_config["packages"]["find"]
package_data = setuptools_config["package-data"]["app"]
self.assertGreaterEqual(
set(project["optional-dependencies"]["dev"]),
{"bandit", "pip-audit", "ruff"},
)
self.assertEqual(setuptools_config["py-modules"], ["main"])
self.assertEqual(package_find["include"], ["app", "app.*"])
self.assertIn("config.sample.yaml", package_data)
self.assertIn("templates/**/*.j2", package_data)
self.assertIn("static/css/*.css", package_data)
self.assertIn("static/js/*.js", package_data)
def test_legacy_requirements_files_are_removed(self):
self.assertFalse((self.repo_root / "requirements.txt").exists())
self.assertFalse((self.repo_root / "requirements-dev.txt").exists())
self.assertFalse((self.repo_root / "app" / "requirements.txt").exists())
def test_package_init_files_exist(self):
self.assertTrue((self.repo_root / "app" / "__init__.py").is_file())
self.assertTrue((self.repo_root / "app" / "utils" / "__init__.py").is_file())
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,43 @@
import unittest
from pathlib import Path
import yaml
SKIP_DIR_NAMES = {".git", ".ruff_cache", "__pycache__", "node_modules"}
SKIP_FILES = {"app/config.yaml"}
YAML_SUFFIXES = {".yml", ".yaml"}
class TestYamlSyntax(unittest.TestCase):
def test_all_repository_yaml_files_are_valid(self):
repo_root = Path(__file__).resolve().parents[2]
invalid_files = []
for path in repo_root.rglob("*"):
if not path.is_file() or path.suffix not in YAML_SUFFIXES:
continue
relative_path = path.relative_to(repo_root).as_posix()
if relative_path in SKIP_FILES:
continue
if any(part in SKIP_DIR_NAMES for part in path.parts):
continue
try:
with path.open("r", encoding="utf-8") as handle:
yaml.safe_load(handle)
except yaml.YAMLError as error:
invalid_files.append((relative_path, str(error).splitlines()[0]))
except Exception as error:
invalid_files.append((relative_path, f"Unexpected error: {error}"))
self.assertFalse(
invalid_files,
"Found invalid YAML files:\n"
+ "\n".join(f"- {path}: {error}" for path, error in invalid_files),
)
if __name__ == "__main__":
unittest.main()

1
tests/lint/__init__.py Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,90 @@
#!/usr/bin/env python3
import ast
import unittest
from pathlib import Path
class TestTestFilesContainUnittestTests(unittest.TestCase):
def setUp(self) -> None:
self.repo_root = Path(__file__).resolve().parents[2]
self.tests_dir = self.repo_root / "tests"
self.assertTrue(
self.tests_dir.is_dir(),
f"'tests' directory not found at: {self.tests_dir}",
)
def _iter_test_files(self) -> list[Path]:
return sorted(self.tests_dir.rglob("test_*.py"))
def _file_contains_runnable_unittest_test(self, path: Path) -> bool:
source = path.read_text(encoding="utf-8")
try:
tree = ast.parse(source, filename=str(path))
except SyntaxError as error:
raise AssertionError(f"SyntaxError in {path}: {error}") from error
testcase_aliases = {"TestCase"}
unittest_aliases = {"unittest"}
for node in tree.body:
if isinstance(node, ast.Import):
for import_name in node.names:
if import_name.name == "unittest":
unittest_aliases.add(import_name.asname or "unittest")
elif isinstance(node, ast.ImportFrom) and node.module == "unittest":
for import_name in node.names:
if import_name.name == "TestCase":
testcase_aliases.add(import_name.asname or "TestCase")
def is_testcase_base(base: ast.expr) -> bool:
if isinstance(base, ast.Name) and base.id in testcase_aliases:
return True
if isinstance(base, ast.Attribute) and base.attr == "TestCase":
return (
isinstance(base.value, ast.Name)
and base.value.id in unittest_aliases
)
return False
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and (
node.name.startswith("test_")
):
return True
for node in tree.body:
if not isinstance(node, ast.ClassDef):
continue
if not any(is_testcase_base(base) for base in node.bases):
continue
for item in node.body:
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and (
item.name.startswith("test_")
):
return True
return False
def test_all_test_py_files_contain_runnable_tests(self) -> None:
test_files = self._iter_test_files()
self.assertTrue(test_files, "No test_*.py files found under tests/")
offenders = []
for path in test_files:
if not self._file_contains_runnable_unittest_test(path):
offenders.append(path.relative_to(self.repo_root).as_posix())
self.assertFalse(
offenders,
"These test_*.py files do not define any unittest-runnable tests:\n"
+ "\n".join(f"- {path}" for path in offenders),
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,25 @@
import unittest
from pathlib import Path
class TestTestFileNaming(unittest.TestCase):
def test_all_python_files_use_test_prefix(self):
tests_root = Path(__file__).resolve().parents[1]
invalid_files = []
for path in tests_root.rglob("*.py"):
if path.name == "__init__.py":
continue
if not path.name.startswith("test_"):
invalid_files.append(path.relative_to(tests_root).as_posix())
self.assertFalse(
invalid_files,
"The following Python files do not start with 'test_':\n"
+ "\n".join(f"- {path}" for path in invalid_files),
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,57 @@
import subprocess
import unittest
from pathlib import Path
import yaml
class TestConfigHygiene(unittest.TestCase):
def setUp(self) -> None:
self.repo_root = Path(__file__).resolve().parents[2]
self.sample_config_path = self.repo_root / "app" / "config.sample.yaml"
def _is_tracked(self, path: str) -> bool:
result = subprocess.run(
["git", "ls-files", "--error-unmatch", path],
cwd=self.repo_root,
check=False,
capture_output=True,
text=True,
)
return result.returncode == 0
def _find_values_for_key(self, data, key_name: str):
if isinstance(data, dict):
for key, value in data.items():
if key == key_name:
yield value
yield from self._find_values_for_key(value, key_name)
elif isinstance(data, list):
for item in data:
yield from self._find_values_for_key(item, key_name)
def test_runtime_only_files_are_ignored_and_untracked(self):
gitignore_lines = (
(self.repo_root / ".gitignore").read_text(encoding="utf-8").splitlines()
)
self.assertIn("app/config.yaml", gitignore_lines)
self.assertIn(".env", gitignore_lines)
self.assertFalse(self._is_tracked("app/config.yaml"))
self.assertFalse(self._is_tracked(".env"))
def test_sample_config_keeps_the_nasa_api_key_placeholder(self):
with self.sample_config_path.open("r", encoding="utf-8") as handle:
sample_config = yaml.safe_load(handle)
nasa_api_keys = list(self._find_values_for_key(sample_config, "nasa_api_key"))
self.assertEqual(
nasa_api_keys,
["YOUR_REAL_KEY_HERE"],
"config.sample.yaml should only contain the documented NASA API key "
"placeholder.",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,43 @@
import unittest
from pathlib import Path
import yaml
ALLOWED_URL_PREFIXES = ("https://", "mailto:", "tel:")
URL_KEYS = {"url", "imprint", "imprint_url"}
class TestSampleConfigUrls(unittest.TestCase):
def setUp(self) -> None:
repo_root = Path(__file__).resolve().parents[2]
sample_config_path = repo_root / "app" / "config.sample.yaml"
with sample_config_path.open("r", encoding="utf-8") as handle:
self.sample_config = yaml.safe_load(handle)
def _iter_urls(self, data, path="root"):
if isinstance(data, dict):
for key, value in data.items():
next_path = f"{path}.{key}"
if key in URL_KEYS and isinstance(value, str):
yield next_path, value
yield from self._iter_urls(value, next_path)
elif isinstance(data, list):
for index, item in enumerate(data):
yield from self._iter_urls(item, f"{path}[{index}]")
def test_sample_config_urls_use_safe_schemes(self):
invalid_urls = [
f"{path} -> {url}"
for path, url in self._iter_urls(self.sample_config)
if not url.startswith(ALLOWED_URL_PREFIXES)
]
self.assertFalse(
invalid_urls,
"The sample config contains URLs with unsupported schemes:\n"
+ "\n".join(f"- {entry}" for entry in invalid_urls),
)
if __name__ == "__main__":
unittest.main()

1
tests/unit/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Unit test package for Portfolio UI."""

View 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()

View 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()

View 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()

View 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()

View 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
View 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()