diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..aa5cc40 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,141 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build-and-scan: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Build prod image + run: docker build -t lumeex:ci . + + - name: Smoke test + run: | + docker run --rm --entrypoint python lumeex:ci -c " + import ast + ast.parse(open('build.py').read()) + ast.parse(open('gallery.py').read()) + print('syntax ok') + " + + - name: Build test image + run: docker build --target test -t lumeex:test . + + - name: Unit tests + run: docker run --rm lumeex:test pytest -v + + - name: Check deprecation warnings + run: docker run --rm --entrypoint python lumeex:ci -W error::DeprecationWarning -c "import src.py.webui.webui" + + - name: Trivy critical (blocking) + run: | + docker run --rm \ + -e DOCKER_HOST=tcp://dockerhost:2375 --add-host=dockerhost:host-gateway \ + -v /var/run/docker.sock:/var/run/docker.sock \ + aquasec/trivy image --exit-code 1 --severity CRITICAL lumeex:ci + + - name: Trivy high (informative) + run: | + docker run --rm \ + -e DOCKER_HOST=tcp://dockerhost:2375 --add-host=dockerhost:host-gateway \ + -v /var/run/docker.sock:/var/run/docker.sock \ + aquasec/trivy image --exit-code 0 --severity HIGH lumeex:ci + + - name: Publish tagged image + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + CI_PUSH_TOKEN: ${{ secrets.CI_PUSH_TOKEN }} + GITEA_URL: https://git.djeex.fr + IMAGE: git.djeex.fr/djeex/lumeex + run: | + set -e + + # Only republish when a container-relevant file actually changed + BEFORE="${{ github.event.before }}" + if [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then + BEFORE="HEAD~1" + fi + CHANGED=$(git diff --name-only "$BEFORE" "${{ github.sha }}" -- \ + Dockerfile requirements.txt VERSION build.py gallery.py \ + src/ config/ docker/.sh/entrypoint.sh || true) + + if [ -z "$CHANGED" ]; then + echo "No container-relevant changes, skipping publish." + exit 0 + fi + + VERSION_BUMPED=false + if ! echo "$CHANGED" | grep -qx "VERSION"; then + CURRENT_VERSION=$(cat VERSION) + MAJOR=$(echo "$CURRENT_VERSION" | cut -d. -f1) + MINOR=$(echo "$CURRENT_VERSION" | cut -d. -f2) + PATCH=$(echo "$CURRENT_VERSION" | cut -d. -f3) + NEW_VERSION="$MAJOR.$MINOR.$((PATCH + 1))" + echo "$NEW_VERSION" > VERSION + + git config user.name "gitea-ci" + git config user.email "ci@git.djeex.fr" + git remote set-url origin "https://gitea-ci:${CI_PUSH_TOKEN}@git.djeex.fr/Djeex/lumeex.git" + git add VERSION + git commit -m "chore: bump version to $NEW_VERSION [skip ci]" + git push origin HEAD:main + git config --unset-all "http.https://git.djeex.fr/Djeex/lumeex.git/.extraheader" || true + VERSION_BUMPED=true + fi + + FULL_VERSION=$(cat VERSION) + MAJOR_MINOR=$(echo "$FULL_VERSION" | cut -d. -f1,2) + + echo "$REGISTRY_TOKEN" | docker login git.djeex.fr -u Djeex --password-stdin + + docker tag lumeex:ci "$IMAGE:latest" + docker tag lumeex:ci "$IMAGE:$MAJOR_MINOR" + docker tag lumeex:ci "$IMAGE:$FULL_VERSION" + docker push "$IMAGE:latest" + docker push "$IMAGE:$MAJOR_MINOR" + docker push "$IMAGE:$FULL_VERSION" + + # Categorize the changelog from the merged PR's labels, if any + PR_NUMBER=$(git log -1 --format=%B "${{ github.sha }}" | grep -oE '#[0-9]+' | head -1 | tr -d '#' || true) + CATEGORY="🔧 Maintenance" + PR_LINK="" + if [ -n "$PR_NUMBER" ]; then + PR_LINK="https://git.djeex.fr/Djeex/lumeex/pulls/$PR_NUMBER" + LABELS=$(curl -s -H "Authorization: token $CI_PUSH_TOKEN" \ + "$GITEA_URL/api/v1/repos/Djeex/lumeex/issues/$PR_NUMBER/labels" | jq -r '.[].name' || true) + if echo "$LABELS" | grep -qx "bug"; then + CATEGORY="⚠️ Hotfix" + elif echo "$LABELS" | grep -qx "major"; then + CATEGORY="💥 Breaking change" + elif echo "$LABELS" | grep -qx "minor"; then + CATEGORY="✨ Update" + fi + fi + + COMMIT_LINK="https://git.djeex.fr/Djeex/lumeex/commit/${{ github.sha }}" + BODY=$(printf '**%s**\n\nCommit: %s' "$CATEGORY" "$COMMIT_LINK") + if [ -n "$PR_LINK" ]; then + BODY=$(printf '%s\nPR: %s' "$BODY" "$PR_LINK") + fi + + jq -n \ + --arg tag "$FULL_VERSION" \ + --arg name "$FULL_VERSION" \ + --arg body "$BODY" \ + '{tag_name: $tag, name: $name, body: $body}' | \ + curl -s -X POST \ + -H "Authorization: token $CI_PUSH_TOKEN" \ + -H "Content-Type: application/json" \ + -d @- \ + "$GITEA_URL/api/v1/repos/Djeex/lumeex/releases" diff --git a/.gitignore b/.gitignore index a833ea8..b377941 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,9 @@ !.env !.sh !.gitignore +!.gitea +!.gitea/** output/ -__pycache__/ \ No newline at end of file +__pycache__/ +htmlcov/ +site_output.zip \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 589c6c8..e125dbd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.13-alpine AS builder +FROM python:3.13.15-alpine AS builder WORKDIR /app @@ -7,7 +7,7 @@ RUN apk add --no-cache gcc musl-dev jpeg-dev zlib-dev COPY requirements.txt . RUN pip wheel --no-cache-dir --wheel-dir=/wheels -r requirements.txt -FROM python:3.13-alpine +FROM python:3.13.15-alpine AS base WORKDIR /app @@ -20,4 +20,13 @@ COPY ./config /app/default COPY ./docker/.sh/entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh +FROM base AS test + +COPY requirements.txt requirements-dev.txt pytest.ini /app/ +RUN pip install --no-cache-dir -r requirements-dev.txt +COPY ./tests/ ./tests/ +COPY ./demo/ ./demo/ + +FROM base + ENTRYPOINT ["/app/entrypoint.sh"] \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..c7b23ec --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = . +testpaths = tests diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..f169cc0 --- /dev/null +++ b/renovate.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"], + "timezone": "Europe/Paris", + "labels": ["bot"], + "packageRules": [ + { + "matchManagers": ["pip_requirements"], + "matchUpdateTypes": ["patch", "minor"], + "automerge": true + }, + { + "matchManagers": ["dockerfile"], + "matchUpdateTypes": ["patch"], + "automerge": true + }, + { + "matchUpdateTypes": ["major"], + "addLabels": ["major"] + }, + { + "matchUpdateTypes": ["minor"], + "addLabels": ["minor"] + } + ], + "vulnerabilityAlerts": { + "addLabels": ["bug"] + } +} diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..f794fa9 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest==8.4.2 +pytest-cov==7.0.0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c77f9d4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,86 @@ +from pathlib import Path + +import pytest +from PIL import Image + +REPO_ROOT = Path(__file__).resolve().parent.parent +DEMO_ROOT = REPO_ROOT / "demo" / "config" + + +@pytest.fixture +def repo_root(): + return REPO_ROOT + + +@pytest.fixture +def demo_root(): + return DEMO_ROOT + + +@pytest.fixture +def make_image(): + """Factory that writes a small real image to disk and returns its path.""" + + def _make(path: Path, size=(64, 48), color=(200, 100, 50), fmt="JPEG", **save_kwargs): + path.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGB", size, color).save(path, fmt, **save_kwargs) + return path + + return _make + + +@pytest.fixture +def app_env(tmp_path, monkeypatch, make_image): + """ + Isolate the Flask webui from the real repo filesystem. + + webui.py resolves most config paths from `Path(__file__).resolve().parents[3]` + (recomputed inline in most routes) or from module-level globals computed once + at import time (PHOTOS_DIR, SITE_YAML). We patch `__file__` so inline lookups + land in a throwaway tmp_path repo skeleton, patch the two import-time globals + directly, and chdir so the plain-relative paths used by gallery_builder + (GALLERY_YAML, GALLERY_DIR, HERO_DIR) resolve there too. + """ + import src.py.webui.webui as webui + + root = tmp_path + (root / "config" / "photos" / "gallery").mkdir(parents=True) + (root / "config" / "photos" / "hero").mkdir(parents=True) + + theme_dir = root / "config" / "themes" / "modern" + theme_dir.mkdir(parents=True) + (theme_dir / "theme.yaml").write_text( + "colors:\n browser_color: '#ffffff'\n" + "favicon:\n path: favicon.png\n" + "google_fonts: []\n", + encoding="utf-8", + ) + make_image(theme_dir / "favicon.png", size=(32, 32), fmt="PNG") + + (root / "config" / "gallery.yaml").write_text( + "hero:\n images: []\ngallery:\n images: []\n", encoding="utf-8" + ) + (root / "config" / "site.yaml").write_text( + "info:\n title: Test\n canonical: https://example.com\n" + "social:\n thumbnail: ''\n", + encoding="utf-8", + ) + + fake_module_file = root / "src" / "py" / "webui" / "webui.py" + fake_module_file.parent.mkdir(parents=True, exist_ok=True) + + monkeypatch.chdir(root) + monkeypatch.setattr(webui, "__file__", str(fake_module_file)) + monkeypatch.setattr(webui, "PHOTOS_DIR", root / "config" / "photos") + monkeypatch.setattr(webui, "SITE_YAML", root / "config" / "site.yaml") + webui.app.config["PHOTOS_DIR"] = root / "config" / "photos" + webui.app.config["TESTING"] = True + + return root + + +@pytest.fixture +def client(app_env): + import src.py.webui.webui as webui + + return webui.app.test_client() diff --git a/tests/test_builder_utils.py b/tests/test_builder_utils.py new file mode 100644 index 0000000..043eb4a --- /dev/null +++ b/tests/test_builder_utils.py @@ -0,0 +1,93 @@ +from pathlib import Path + +from src.py.builder import utils + + +def test_load_yaml_missing_file_returns_empty_dict(tmp_path, caplog): + result = utils.load_yaml(tmp_path / "missing.yaml") + assert result == {} + + +def test_load_yaml_parses_existing_file(tmp_path): + path = tmp_path / "data.yaml" + path.write_text("foo: bar\nnum: 1\n", encoding="utf-8") + assert utils.load_yaml(path) == {"foo": "bar", "num": 1} + + +def test_load_theme_config_success(tmp_path): + themes_dir = tmp_path / "themes" + theme_dir = themes_dir / "modern" + theme_dir.mkdir(parents=True) + (theme_dir / "theme.yaml").write_text("colors:\n primary: '#000'\n", encoding="utf-8") + + theme_vars, returned_dir = utils.load_theme_config("modern", themes_dir) + + assert theme_vars == {"colors": {"primary": "#000"}} + assert returned_dir == theme_dir + + +def test_load_theme_config_missing_raises(tmp_path): + themes_dir = tmp_path / "themes" + try: + utils.load_theme_config("missing", themes_dir) + assert False, "expected FileNotFoundError" + except FileNotFoundError: + pass + + +def test_clear_dir_creates_missing_dir(tmp_path): + target = tmp_path / "out" + utils.clear_dir(target) + assert target.is_dir() + assert list(target.iterdir()) == [] + + +def test_clear_dir_removes_existing_content(tmp_path): + target = tmp_path / "out" + target.mkdir() + (target / "file.txt").write_text("x", encoding="utf-8") + sub = target / "sub" + sub.mkdir() + (sub / "nested.txt").write_text("y", encoding="utf-8") + + utils.clear_dir(target) + + assert target.is_dir() + assert list(target.iterdir()) == [] + + +def test_ensure_dir_creates_and_clears(tmp_path): + target = tmp_path / "out" + utils.ensure_dir(target) + assert target.is_dir() + + (target / "stale.txt").write_text("x", encoding="utf-8") + utils.ensure_dir(target) + assert list(target.iterdir()) == [] + + +def test_copy_assets_copies_existing_folders(tmp_path): + js_dir = tmp_path / "js" + style_dir = tmp_path / "style" + build_dir = tmp_path / "output" + js_dir.mkdir() + style_dir.mkdir() + (js_dir / "app.js").write_text("console.log(1)", encoding="utf-8") + (style_dir / "style.css").write_text("body{}", encoding="utf-8") + + utils.copy_assets(js_dir, style_dir, build_dir) + + assert (build_dir / "js" / "app.js").exists() + assert (build_dir / "style" / "style.css").exists() + + +def test_copy_assets_skips_missing_folder(tmp_path, caplog): + js_dir = tmp_path / "missing_js" + style_dir = tmp_path / "style" + style_dir.mkdir() + build_dir = tmp_path / "output" + + utils.copy_assets(js_dir, style_dir, build_dir) + + assert not (build_dir / "missing_js").exists() + assert (build_dir / "style").exists() diff --git a/tests/test_css_generator.py b/tests/test_css_generator.py new file mode 100644 index 0000000..c9d10c1 --- /dev/null +++ b/tests/test_css_generator.py @@ -0,0 +1,68 @@ +from src.py.builder import css_generator as cg + + +def test_generate_css_variables(tmp_path): + output_path = tmp_path / "style" / "colors.css" + cg.generate_css_variables({"primary_dark": "#005384", "accent": "#FFC700"}, output_path) + + content = output_path.read_text(encoding="utf-8") + assert ":root {" in content + assert "--color-primary-dark: #005384;" in content + assert "--color-accent: #FFC700;" in content + + +def test_generate_fonts_css_with_font_files_and_config(tmp_path): + fonts_dir = tmp_path / "theme" / "fonts" + fonts_dir.mkdir(parents=True) + (fonts_dir / "trixie.woff2").write_bytes(b"fake-woff2") + (fonts_dir / "trixie.woff").write_bytes(b"fake-woff") + (fonts_dir / "readme.txt").write_text("skip me", encoding="utf-8") + + output_path = tmp_path / "build" / "style" / "fonts.css" + fonts_cfg = { + "primary": {"name": "Lato", "fallback": "sans-serif"}, + "secondary": {"name": "Trixie", "fallback": "monospace"}, + } + + preload_links = cg.generate_fonts_css(fonts_dir, output_path, fonts_cfg=fonts_cfg) + + content = output_path.read_text(encoding="utf-8") + assert "@font-face {" in content + assert "font-family: 'trixie';" in content + assert "--font-primary: 'Lato', sans-serif;" in content + assert "--font-secondary: 'Trixie', monospace;" in content + + assert (tmp_path / "build" / "fonts" / "trixie.woff2").exists() + assert (tmp_path / "build" / "fonts" / "trixie.woff").exists() + assert len(preload_links) == 2 + assert any("trixie.woff2" in link for link in preload_links) + + +def test_generate_fonts_css_with_no_fonts(tmp_path): + fonts_dir = tmp_path / "empty_fonts" + fonts_dir.mkdir() + output_path = tmp_path / "build" / "fonts.css" + + preload_links = cg.generate_fonts_css(fonts_dir, output_path) + + assert preload_links == [] + assert output_path.exists() + + +def test_generate_google_fonts_link_empty(): + assert cg.generate_google_fonts_link([]) == "" + assert cg.generate_google_fonts_link(None) == "" + + +def test_generate_google_fonts_link_with_weights(): + fonts = [ + {"family": "Lato", "weights": ["200", "400", "700"]}, + {"family": "Open Sans"}, + ] + + link = cg.generate_google_fonts_link(fonts) + + assert "family=Lato:wght@200;400;700" in link + assert "family=Open+Sans" in link + assert link.startswith('') diff --git a/tests/test_gallery_builder.py b/tests/test_gallery_builder.py new file mode 100644 index 0000000..245a043 --- /dev/null +++ b/tests/test_gallery_builder.py @@ -0,0 +1,113 @@ +import yaml + +from src.py.builder import gallery_builder as gb + + +def _setup_repo(tmp_path, monkeypatch): + (tmp_path / "config" / "photos" / "gallery").mkdir(parents=True) + (tmp_path / "config" / "photos" / "hero").mkdir(parents=True) + monkeypatch.chdir(tmp_path) + + +def test_load_yaml_missing_file(tmp_path, monkeypatch): + _setup_repo(tmp_path, monkeypatch) + assert gb.load_yaml("config/gallery.yaml") == {} + + +def test_load_yaml_and_save_yaml_roundtrip(tmp_path, monkeypatch): + _setup_repo(tmp_path, monkeypatch) + data = {"gallery": {"images": [{"src": "gallery/a.jpg", "tags": ["x"]}]}} + gb.save_yaml(data, "config/gallery.yaml") + + loaded = gb.load_yaml("config/gallery.yaml") + assert loaded == data + + +def test_get_all_image_paths_filters_and_sorts(tmp_path, monkeypatch, make_image): + _setup_repo(tmp_path, monkeypatch) + gallery_dir = tmp_path / "config" / "photos" / "gallery" + make_image(gallery_dir / "b.jpg") + make_image(gallery_dir / "a.png", fmt="PNG") + (gallery_dir / "notes.txt").write_text("ignore me", encoding="utf-8") + + paths = gb.get_all_image_paths(gallery_dir) + + assert paths == ["gallery/a.png", "gallery/b.jpg"] + + +def test_update_gallery_adds_new_and_removes_deleted(tmp_path, monkeypatch, make_image, capsys): + _setup_repo(tmp_path, monkeypatch) + gallery_dir = tmp_path / "config" / "photos" / "gallery" + make_image(gallery_dir / "kept.jpg") + make_image(gallery_dir / "new.jpg") + + gb.save_yaml( + { + "gallery": { + "images": [ + {"src": "gallery/kept.jpg", "tags": ["portrait"]}, + {"src": "gallery/gone.jpg", "tags": ["stale"]}, + ] + } + }, + gb.GALLERY_YAML, + ) + + gb.update_gallery() + + result = gb.load_yaml(gb.GALLERY_YAML) + images = {img["src"]: img for img in result["gallery"]["images"]} + assert set(images) == {"gallery/kept.jpg", "gallery/new.jpg"} + assert images["gallery/kept.jpg"]["tags"] == ["portrait"] + assert images["gallery/new.jpg"]["tags"] == [] + + +def test_update_gallery_no_changes(tmp_path, monkeypatch, make_image, capsys): + _setup_repo(tmp_path, monkeypatch) + gallery_dir = tmp_path / "config" / "photos" / "gallery" + make_image(gallery_dir / "only.jpg") + gb.save_yaml( + {"gallery": {"images": [{"src": "gallery/only.jpg", "tags": []}]}}, + gb.GALLERY_YAML, + ) + + gb.update_gallery() + + out = capsys.readouterr().out + assert "No changes to gallery.yaml (gallery)" in out + + +def test_update_hero_adds_new_and_removes_deleted(tmp_path, monkeypatch, make_image): + _setup_repo(tmp_path, monkeypatch) + hero_dir = tmp_path / "config" / "photos" / "hero" + make_image(hero_dir / "kept.jpg") + make_image(hero_dir / "new.jpg") + + gb.save_yaml( + { + "hero": { + "images": [ + {"src": "hero/kept.jpg"}, + {"src": "hero/gone.jpg"}, + ] + } + }, + gb.GALLERY_YAML, + ) + + gb.update_hero() + + result = gb.load_yaml(gb.GALLERY_YAML) + srcs = {img["src"] for img in result["hero"]["images"]} + assert srcs == {"hero/kept.jpg", "hero/new.jpg"} + + +def test_update_gallery_on_empty_yaml_initializes_section(tmp_path, monkeypatch, make_image): + _setup_repo(tmp_path, monkeypatch) + gallery_dir = tmp_path / "config" / "photos" / "gallery" + make_image(gallery_dir / "only.jpg") + + gb.update_gallery() + + result = gb.load_yaml(gb.GALLERY_YAML) + assert result["gallery"]["images"][0]["src"] == "gallery/only.jpg" diff --git a/tests/test_html_generator.py b/tests/test_html_generator.py new file mode 100644 index 0000000..908f5d8 --- /dev/null +++ b/tests/test_html_generator.py @@ -0,0 +1,67 @@ +import json + +from src.py.builder import html_generator as hg + + +def test_render_template_replaces_placeholders(tmp_path): + template = tmp_path / "t.html" + template.write_text("
{{ missing }}
", encoding="utf-8") + + result = hg.render_template(template, {"title": "Hello", "missing": None}) + + assert result == "