Merge pull request 'Add pytest suite and Gitea Actions CI/CD pipeline' (#29) from dev-ci into main
CI / build-and-scan (push) Successful in 1m14s

Reviewed-on: #29
This commit was merged in pull request #29.
This commit is contained in:
2026-08-23 10:47:18 +02:00
15 changed files with 1232 additions and 3 deletions
+141
View File
@@ -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 "[email protected]"
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"
+5 -1
View File
@@ -2,5 +2,9 @@
!.env !.env
!.sh !.sh
!.gitignore !.gitignore
!.gitea
!.gitea/**
output/ output/
__pycache__/ __pycache__/
htmlcov/
site_output.zip
+11 -2
View File
@@ -1,4 +1,4 @@
FROM python:3.13-alpine AS builder FROM python:3.13.15-alpine AS builder
WORKDIR /app WORKDIR /app
@@ -7,7 +7,7 @@ RUN apk add --no-cache gcc musl-dev jpeg-dev zlib-dev
COPY requirements.txt . COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir=/wheels -r 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 WORKDIR /app
@@ -20,4 +20,13 @@ COPY ./config /app/default
COPY ./docker/.sh/entrypoint.sh /app/entrypoint.sh COPY ./docker/.sh/entrypoint.sh /app/entrypoint.sh
RUN chmod +x /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"] ENTRYPOINT ["/app/entrypoint.sh"]
+3
View File
@@ -0,0 +1,3 @@
[pytest]
pythonpath = .
testpaths = tests
+29
View File
@@ -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"]
}
}
+3
View File
@@ -0,0 +1,3 @@
-r requirements.txt
pytest==8.4.2
pytest-cov==7.0.0
+86
View File
@@ -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()
+93
View File
@@ -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()
+68
View File
@@ -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('<link href="https://fonts.googleapis.com/css2?')
assert link.endswith('&display=swap" rel="stylesheet">')
+113
View File
@@ -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"
+67
View File
@@ -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("<h1>{{ title }}</h1><p>{{ missing }}</p>", encoding="utf-8")
result = hg.render_template(template, {"title": "Hello", "missing": None})
assert result == "<h1>Hello</h1><p></p>"
def test_render_template_leaves_unknown_placeholders(tmp_path):
template = tmp_path / "t.html"
template.write_text("<h1>{{ title }}</h1>", encoding="utf-8")
result = hg.render_template(template, {})
assert result == "<h1>{{ title }}</h1>"
def test_render_gallery_images_with_and_without_tags():
images = [
{"src": "gallery/a.jpg", "tags": ["nature", "sky"], "alt": "A photo"},
{"src": "gallery/b.jpg"},
]
html = hg.render_gallery_images(images)
assert 'data-tags="nature sky"' in html
assert '<span class="tag">#nature</span>' in html
assert '<span class="tag">#sky</span>' in html
assert 'data-src="/img/gallery/a.jpg"' in html
assert 'alt="A photo"' in html
assert 'data-tags=""' in html
assert 'data-src="/img/gallery/b.jpg"' in html
assert 'alt=""' in html
def test_generate_gallery_json_from_images(tmp_path):
images = [{"src": "hero/a.jpg"}, {"src": "hero/b.jpg"}]
hg.generate_gallery_json_from_images(images, tmp_path)
output_path = tmp_path / "data" / "gallery.json"
assert json.loads(output_path.read_text(encoding="utf-8")) == ["hero/a.jpg", "hero/b.jpg"]
def test_generate_robots_txt(tmp_path):
hg.generate_robots_txt("https://example.com/", ["/", "legals"], tmp_path)
content = (tmp_path / "robots.txt").read_text(encoding="utf-8")
assert "Disallow: /" in content
assert "Allow: /" in content
assert "Allow: /legals" in content
assert "Sitemap: https://example.com/sitemap.xml" in content
def test_generate_sitemap_xml(tmp_path):
hg.generate_sitemap_xml("https://example.com", ["/", "/legals/"], tmp_path)
content = (tmp_path / "sitemap.xml").read_text(encoding="utf-8")
assert "<loc>https://example.com/</loc>" in content
assert "<loc>https://example.com/legals/</loc>" in content
assert content.startswith('<?xml version="1.0" encoding="UTF-8"?>')
+134
View File
@@ -0,0 +1,134 @@
from pathlib import Path
from PIL import Image
from src.py.builder import image_processor as ip
def test_convert_and_resize_image_missing_input_logs_and_skips(tmp_path):
output_path = tmp_path / "out.webp"
ip.convert_and_resize_image(tmp_path / "missing.jpg", output_path)
assert not output_path.exists()
def test_convert_and_resize_image_resizes_down(tmp_path, make_image):
input_path = make_image(tmp_path / "in.jpg", size=(2000, 1000))
output_path = tmp_path / "out.webp"
ip.convert_and_resize_image(input_path, output_path, resize=True, max_width=1000)
actual_output = output_path if output_path.exists() else output_path.with_suffix(".jpg")
assert actual_output.exists()
with Image.open(actual_output) as img:
assert img.width == 1000
assert img.height == 500
def test_convert_and_resize_image_no_resize_keeps_dimensions(tmp_path, make_image):
input_path = make_image(tmp_path / "in.jpg", size=(500, 300))
output_path = tmp_path / "out.webp"
ip.convert_and_resize_image(input_path, output_path, resize=False)
actual_output = output_path if output_path.exists() else output_path.with_suffix(".jpg")
with Image.open(actual_output) as img:
assert img.size == (500, 300)
def test_convert_and_resize_image_preserves_icc_profile(tmp_path):
input_path = tmp_path / "in.jpg"
icc_bytes = b"fake-icc-profile-bytes"
Image.new("RGB", (100, 80), (10, 20, 30)).save(input_path, "JPEG", icc_profile=icc_bytes)
output_path = tmp_path / "out.webp"
ip.convert_and_resize_image(input_path, output_path, resize=False)
actual_output = output_path if output_path.exists() else output_path.with_suffix(".jpg")
with Image.open(actual_output) as img:
assert img.info.get("icc_profile") == icc_bytes
def test_process_images_updates_src_to_processed_extension(tmp_path, make_image):
img_dir = tmp_path / "photos"
build_dir = tmp_path / "output"
make_image(img_dir / "gallery" / "a.jpg", size=(200, 200))
images = [{"src": "gallery/a.jpg", "tags": []}]
ip.process_images(images, resize_images=True, img_dir=img_dir, build_dir=build_dir)
assert images[0]["src"] in ("gallery/a.webp", "gallery/a.jpg")
produced = build_dir / "img" / images[0]["src"]
assert produced.exists()
def test_copy_original_images_copies_existing_and_skips_missing(tmp_path, make_image, caplog):
img_dir = tmp_path / "photos"
build_dir = tmp_path / "output"
make_image(img_dir / "gallery" / "present.jpg")
images = [{"src": "gallery/present.jpg"}, {"src": "gallery/absent.jpg"}]
ip.copy_original_images(images, img_dir, build_dir)
assert (build_dir / "img" / "gallery" / "present.jpg").exists()
assert not (build_dir / "img" / "gallery" / "absent.jpg").exists()
def test_get_favicon_path_missing_config_returns_none(tmp_path):
assert ip.get_favicon_path({}, tmp_path) is None
def test_get_favicon_path_relative_and_absolute(tmp_path, make_image):
theme_dir = tmp_path / "theme"
icon_path = make_image(theme_dir / "favicon.png", fmt="PNG")
result = ip.get_favicon_path({"favicon": {"path": "favicon.png"}}, theme_dir)
assert result == icon_path
absolute_result = ip.get_favicon_path({"favicon": {"path": str(icon_path)}}, theme_dir)
assert absolute_result == icon_path
def test_get_favicon_path_file_not_found(tmp_path):
result = ip.get_favicon_path({"favicon": {"path": "missing.png"}}, tmp_path)
assert result is None
def test_generate_favicons_from_logo_creates_all_sizes(tmp_path, make_image):
theme_dir = tmp_path / "theme"
make_image(theme_dir / "favicon.png", size=(256, 256), fmt="PNG")
output_dir = tmp_path / "output" / "favicon"
ip.generate_favicons_from_logo({"favicon": {"path": "favicon.png"}}, theme_dir, output_dir)
expected = [
"favicon-32.png", "favicon-96.png", "favicon-128.png",
"favicon-192.png", "favicon-196.png", "favicon-152.png", "favicon-180.png",
]
for name in expected:
assert (output_dir / name).exists()
with Image.open(output_dir / "favicon-32.png") as img:
assert img.size == (32, 32)
def test_generate_favicons_from_logo_no_favicon_skips(tmp_path):
output_dir = tmp_path / "output" / "favicon"
ip.generate_favicons_from_logo({}, tmp_path, output_dir)
assert not output_dir.exists()
def test_generate_favicon_ico_creates_file(tmp_path, make_image):
theme_dir = tmp_path / "theme"
make_image(theme_dir / "favicon.png", size=(64, 64), fmt="PNG")
output_path = tmp_path / "output" / "favicon.ico"
ip.generate_favicon_ico({"favicon": {"path": "favicon.png"}}, theme_dir, output_path)
assert output_path.exists()
with Image.open(output_path) as img:
assert img.format == "ICO"
def test_generate_favicon_ico_no_favicon_skips(tmp_path):
output_path = tmp_path / "output" / "favicon.ico"
ip.generate_favicon_ico({}, tmp_path, output_path)
assert not output_path.exists()
+85
View File
@@ -0,0 +1,85 @@
import yaml
from src.py.builder import site_builder
def test_build_end_to_end_with_demo_content(tmp_path, monkeypatch, demo_root):
output_dir = tmp_path / "output"
monkeypatch.setattr(site_builder, "BUILD_DIR", output_dir)
monkeypatch.setattr(site_builder, "GALLERY_FILE", demo_root / "gallery.yaml")
monkeypatch.setattr(site_builder, "SITE_FILE", demo_root / "site.yaml")
monkeypatch.setattr(site_builder, "IMG_DIR", demo_root / "photos")
monkeypatch.setattr(site_builder, "THEMES_DIR", demo_root / "themes")
site_builder.build()
index_html = (output_dir / "index.html").read_text(encoding="utf-8")
assert "<h1>Lumeex</h1>" in index_html
assert "https://lumeex.djeex.fr/" in index_html
assert '<img class="fade-in-img lazyload"' in index_html
assert (output_dir / "legals" / "index.html").exists()
assert (output_dir / "robots.txt").exists()
assert (output_dir / "sitemap.xml").exists()
assert (output_dir / "data" / "gallery.json").exists()
assert (output_dir / "style" / "colors.css").exists()
assert (output_dir / "style" / "fonts.css").exists()
assert (output_dir / "style" / "theme.css").exists()
assert (output_dir / "favicon.ico").exists()
assert (output_dir / "img" / "favicon" / "favicon-192.png").exists()
assert (output_dir / "img" / "social").is_dir()
assert any((output_dir / "img" / "social").iterdir())
# convert_images=true in the demo config: originals become webp (or jpg fallback)
processed = list((output_dir / "img" / "gallery").glob("*"))
assert processed
assert all(p.suffix in (".webp", ".jpg") for p in processed)
def test_build_without_image_conversion_copies_originals(tmp_path, monkeypatch, demo_root):
site_data = yaml.safe_load((demo_root / "site.yaml").read_text(encoding="utf-8"))
site_data["build"]["convert_images"] = False
site_data["build"]["resize_images"] = False
site_file = tmp_path / "site.yaml"
site_file.write_text(yaml.dump(site_data), encoding="utf-8")
output_dir = tmp_path / "output"
monkeypatch.setattr(site_builder, "BUILD_DIR", output_dir)
monkeypatch.setattr(site_builder, "GALLERY_FILE", demo_root / "gallery.yaml")
monkeypatch.setattr(site_builder, "SITE_FILE", site_file)
monkeypatch.setattr(site_builder, "IMG_DIR", demo_root / "photos")
monkeypatch.setattr(site_builder, "THEMES_DIR", demo_root / "themes")
site_builder.build()
copied = list((output_dir / "img" / "gallery").glob("*.jpg"))
assert copied, "original jpg files should be copied unmodified"
def test_build_minimal_site_skips_optional_sections(tmp_path, monkeypatch, demo_root):
gallery_file = tmp_path / "gallery.yaml"
gallery_file.write_text("hero:\n images: []\ngallery:\n images: []\n", encoding="utf-8")
site_file = tmp_path / "site.yaml"
site_file.write_text(
"info:\n title: Minimal\n"
"menu:\n items: []\n"
"build:\n theme: modern\n convert_images: true\n resize_images: true\n",
encoding="utf-8",
)
output_dir = tmp_path / "output"
monkeypatch.setattr(site_builder, "BUILD_DIR", output_dir)
monkeypatch.setattr(site_builder, "GALLERY_FILE", gallery_file)
monkeypatch.setattr(site_builder, "SITE_FILE", site_file)
monkeypatch.setattr(site_builder, "IMG_DIR", demo_root / "photos")
monkeypatch.setattr(site_builder, "THEMES_DIR", demo_root / "themes")
site_builder.build()
assert (output_dir / "index.html").exists()
assert not (output_dir / "legals").exists()
assert not (output_dir / "robots.txt").exists()
assert not (output_dir / "sitemap.xml").exists()
assert not (output_dir / "data" / "gallery.json").exists()
assert not (output_dir / "img" / "social").exists()
+82
View File
@@ -0,0 +1,82 @@
import io
from src.py.webui.upload import allowed_file, save_uploaded_file
def test_allowed_file_accepts_known_extensions():
for name in ["a.jpg", "a.JPEG", "a.png", "a.webp"]:
assert allowed_file(name) is True
def test_allowed_file_rejects_unknown_or_missing_extension():
assert allowed_file("a.gif") is False
assert allowed_file("noextension") is False
def test_save_uploaded_file_sanitizes_filename(tmp_path):
class FakeFile:
filename = "../../evil.jpg"
def save(self, path):
self.saved_path = path
folder = tmp_path / "gallery"
fake_file = FakeFile()
result = save_uploaded_file(fake_file, folder)
assert result == "evil.jpg"
assert fake_file.saved_path == folder / "evil.jpg"
assert folder.exists()
def test_upload_photo_invalid_section(client):
resp = client.post(
"/api/invalid/upload",
data={"files": (io.BytesIO(b"data"), "a.jpg")},
content_type="multipart/form-data",
)
assert resp.status_code == 400
def test_upload_photo_no_files_key(client):
resp = client.post("/api/gallery/upload", data={}, content_type="multipart/form-data")
assert resp.status_code == 400
def test_upload_photo_skips_disallowed_extensions(client):
resp = client.post(
"/api/gallery/upload",
data={"files": (io.BytesIO(b"data"), "notes.txt")},
content_type="multipart/form-data",
)
assert resp.status_code == 400
assert "No valid files uploaded" in resp.get_json()["error"]
def test_upload_photo_saves_valid_files_and_updates_gallery(client, app_env):
resp = client.post(
"/api/gallery/upload",
data={"files": (io.BytesIO(b"fake-jpg-bytes"), "photo.jpg")},
content_type="multipart/form-data",
)
body = resp.get_json()
assert body["status"] == "ok"
assert body["uploaded"] == ["photo.jpg"]
assert (app_env / "config" / "photos" / "gallery" / "photo.jpg").exists()
gallery = client.get("/api/gallery").get_json()
assert gallery[0]["src"] == "gallery/photo.jpg"
def test_upload_photo_hero_section(client, app_env):
resp = client.post(
"/api/hero/upload",
data={"files": (io.BytesIO(b"fake-jpg-bytes"), "photo.jpg")},
content_type="multipart/form-data",
)
assert resp.get_json()["status"] == "ok"
assert (app_env / "config" / "photos" / "hero" / "photo.jpg").exists()
hero = client.get("/api/hero").get_json()
assert hero[0]["src"] == "hero/photo.jpg"
+312
View File
@@ -0,0 +1,312 @@
import io
import zipfile
import yaml
def test_index_page(client):
resp = client.get("/")
assert resp.status_code == 200
def test_get_gallery_and_hero_start_empty(client):
assert client.get("/api/gallery").get_json() == []
assert client.get("/api/hero").get_json() == []
def test_update_gallery_and_hero(client, app_env):
images = [{"src": "gallery/a.jpg", "tags": ["x"]}]
resp = client.post("/api/gallery/update", json=images)
assert resp.get_json() == {"status": "ok"}
assert client.get("/api/gallery").get_json() == images
hero_images = [{"src": "hero/a.jpg"}]
resp = client.post("/api/hero/update", json=hero_images)
assert resp.get_json() == {"status": "ok"}
assert client.get("/api/hero").get_json() == hero_images
def test_refresh_gallery_and_hero_pick_up_new_files(client, app_env, make_image):
make_image(app_env / "config" / "photos" / "gallery" / "new.jpg")
make_image(app_env / "config" / "photos" / "hero" / "new.jpg")
client.post("/api/gallery/refresh")
client.post("/api/hero/refresh")
gallery = client.get("/api/gallery").get_json()
hero = client.get("/api/hero").get_json()
assert gallery[0]["src"] == "gallery/new.jpg"
assert hero[0]["src"] == "hero/new.jpg"
def test_delete_gallery_photo(client, app_env, make_image):
make_image(app_env / "config" / "photos" / "gallery" / "a.jpg")
resp = client.post("/api/gallery/delete", json={"src": "a.jpg"})
assert resp.get_json() == {"status": "ok"}
assert not (app_env / "config" / "photos" / "gallery" / "a.jpg").exists()
def test_delete_gallery_photo_not_found(client):
resp = client.post("/api/gallery/delete", json={"src": "missing.jpg"})
assert resp.status_code == 404
def test_delete_hero_photo(client, app_env, make_image):
make_image(app_env / "config" / "photos" / "hero" / "a.jpg")
resp = client.post("/api/hero/delete", json={"src": "a.jpg"})
assert resp.get_json() == {"status": "ok"}
assert not (app_env / "config" / "photos" / "hero" / "a.jpg").exists()
def test_delete_all_gallery_photos(client, app_env, make_image):
make_image(app_env / "config" / "photos" / "gallery" / "a.jpg")
make_image(app_env / "config" / "photos" / "gallery" / "b.jpg")
client.post("/api/gallery/update", json=[{"src": "gallery/a.jpg"}, {"src": "gallery/b.jpg"}])
resp = client.post("/api/gallery/delete_all")
body = resp.get_json()
assert body["status"] == "ok"
assert body["deleted"] == 2
assert client.get("/api/gallery").get_json() == []
assert list((app_env / "config" / "photos" / "gallery").iterdir()) == []
def test_delete_all_hero_photos(client, app_env, make_image):
make_image(app_env / "config" / "photos" / "hero" / "a.jpg")
client.post("/api/hero/update", json=[{"src": "hero/a.jpg"}])
resp = client.post("/api/hero/delete_all")
body = resp.get_json()
assert body["deleted"] == 1
assert client.get("/api/hero").get_json() == []
def test_serve_photo_by_section(client, app_env, make_image):
make_image(app_env / "config" / "photos" / "gallery" / "a.jpg")
resp = client.get("/photos/gallery/a.jpg")
assert resp.status_code == 200
def test_serve_photo_at_root(client, app_env, make_image):
make_image(app_env / "config" / "photos" / "thumbnail.png", fmt="PNG")
resp = client.get("/photos/thumbnail.png")
assert resp.status_code == 200
def test_site_info_get_and_post(client, app_env):
resp = client.get("/api/site-info")
data = resp.get_json()
assert data["info"]["title"] == "Test"
client.post("/api/site-info", json={"info": {"subtitle": "New subtitle"}, "social": {"instagram_url": "https://insta.example"}})
updated = client.get("/api/site-info").get_json()
assert updated["info"]["title"] == "Test"
assert updated["info"]["subtitle"] == "New subtitle"
assert updated["social"]["instagram_url"] == "https://insta.example"
def test_list_themes(client):
assert client.get("/api/themes").get_json() == ["modern"]
def test_thumbnail_upload_and_remove(client, app_env):
data = {"file": (io.BytesIO(b"fake-png-bytes"), "thumb.png")}
resp = client.post("/api/thumbnail/upload", data=data, content_type="multipart/form-data")
body = resp.get_json()
assert body["status"] == "ok"
assert (app_env / "config" / "photos" / "thumbnail.png").exists()
site_info = client.get("/api/site-info").get_json()
assert site_info["social"]["thumbnail"] == "thumbnail.png"
resp = client.post("/api/thumbnail/remove")
assert resp.get_json() == {"status": "ok"}
assert not (app_env / "config" / "photos" / "thumbnail.png").exists()
site_info = client.get("/api/site-info").get_json()
assert site_info["social"]["thumbnail"] == ""
def test_thumbnail_upload_missing_file(client):
resp = client.post("/api/thumbnail/upload", data={}, content_type="multipart/form-data")
assert resp.status_code == 400
def test_theme_upload_and_remove(client, app_env):
data = {
"files": [
(io.BytesIO(b"colors: {}"), "custom/theme.yaml"),
(io.BytesIO(b"body{}"), "custom/theme.css"),
]
}
resp = client.post("/api/theme/upload", data=data, content_type="multipart/form-data")
body = resp.get_json()
assert body["status"] == "ok"
assert body["theme"] == "custom"
assert (app_env / "config" / "themes" / "custom" / "theme.yaml").exists()
assert (app_env / "config" / "themes" / "custom" / "theme.css").exists()
resp = client.post("/api/theme/remove", json={"theme": "custom"})
assert resp.get_json() == {"status": "ok"}
assert not (app_env / "config" / "themes" / "custom").exists()
def test_theme_remove_protected_theme(client):
resp = client.post("/api/theme/remove", json={"theme": "modern"})
assert resp.status_code == 400
def test_theme_remove_missing_theme(client):
resp = client.post("/api/theme/remove", json={"theme": "does-not-exist"})
assert resp.status_code == 404
def test_theme_info_get_and_post(client, app_env):
resp = client.get("/api/theme-info")
body = resp.get_json()
assert body["theme_name"] == "modern"
assert body["theme_yaml"]["favicon"]["path"] == "favicon.png"
new_theme_yaml = dict(body["theme_yaml"])
new_theme_yaml["colors"] = {"primary": "#123456"}
resp = client.post(
"/api/theme-info",
json={"theme_name": "modern", "theme_yaml": new_theme_yaml},
)
assert resp.get_json() == {"status": "ok"}
saved = yaml.safe_load((app_env / "config" / "themes" / "modern" / "theme.yaml").read_text())
assert saved["colors"]["primary"] == "#123456"
def test_update_theme_google_fonts(client, app_env):
resp = client.post(
"/api/theme-google-fonts",
json={"theme_name": "modern", "google_fonts": [{"family": "Lato"}]},
)
assert resp.get_json() == {"status": "ok"}
saved = yaml.safe_load((app_env / "config" / "themes" / "modern" / "theme.yaml").read_text())
assert saved["google_fonts"] == [{"family": "Lato"}]
def test_local_fonts_empty_then_populated(client, app_env):
assert client.get("/api/local-fonts?theme=modern").get_json() == []
fonts_dir = app_env / "config" / "themes" / "modern" / "fonts"
fonts_dir.mkdir(parents=True)
(fonts_dir / "trixie.woff2").write_bytes(b"fake")
assert client.get("/api/local-fonts?theme=modern").get_json() == ["trixie.woff2"]
def test_favicon_upload_invalid_extension(client):
data = {"theme": "modern", "file": (io.BytesIO(b"not-an-image"), "favicon.txt")}
resp = client.post("/api/favicon/upload", data=data, content_type="multipart/form-data")
assert resp.status_code == 400
def test_favicon_upload_and_remove(client, app_env):
data = {"theme": "modern", "file": (io.BytesIO(b"fake-png"), "favicon.png")}
resp = client.post("/api/favicon/upload", data=data, content_type="multipart/form-data")
assert resp.get_json()["status"] == "ok"
assert (app_env / "config" / "themes" / "modern" / "favicon.png").exists()
resp = client.post("/api/favicon/remove", json={"theme": "modern"})
assert resp.get_json() == {"status": "ok"}
assert not (app_env / "config" / "themes" / "modern" / "favicon.png").exists()
def test_serve_theme_asset(client, app_env):
(app_env / "config" / "themes" / "modern" / "theme.css").write_text("body{}", encoding="utf-8")
resp = client.get("/themes/modern/theme.css")
assert resp.status_code == 200
def test_font_upload_invalid_extension(client):
data = {"theme": "modern", "file": (io.BytesIO(b"data"), "font.ttf")}
resp = client.post("/api/font/upload", data=data, content_type="multipart/form-data")
assert resp.status_code == 400
def test_font_upload_and_remove(client, app_env):
data = {"theme": "modern", "file": (io.BytesIO(b"data"), "trixie.woff2")}
resp = client.post("/api/font/upload", data=data, content_type="multipart/form-data")
body = resp.get_json()
assert body["status"] == "ok"
assert body["filename"] == "trixie"
resp = client.post("/api/font/remove", json={"theme": "modern", "font": "trixie.woff2"})
assert resp.get_json() == {"status": "ok"}
def test_font_remove_missing(client):
resp = client.post("/api/font/remove", json={"theme": "modern", "font": "missing.woff2"})
assert resp.status_code == 404
def test_trigger_build_missing_site_yaml(client, app_env):
(app_env / "config" / "site.yaml").unlink()
resp = client.post("/api/build")
assert resp.status_code == 400
assert "site.yaml not found" in resp.get_json()["message"]
def test_trigger_build_missing_required_field(client, app_env):
resp = client.post("/api/build")
assert resp.status_code == 400
assert "Site info are not set" in resp.get_json()["message"]
def test_trigger_build_success_invokes_subprocess(client, app_env, monkeypatch):
import src.py.webui.webui as webui
(app_env / "config" / "site.yaml").write_text(
"info:\n title: Test\n canonical: https://example.com\nsocial:\n thumbnail: t.png\n",
encoding="utf-8",
)
calls = []
monkeypatch.setattr(
webui.subprocess, "run", lambda *a, **k: calls.append((a, k))
)
resp = client.post("/api/build")
assert resp.get_json() == {"status": "ok"}
assert calls
def test_trigger_build_subprocess_failure_returns_500(client, app_env, monkeypatch):
import src.py.webui.webui as webui
(app_env / "config" / "site.yaml").write_text(
"info:\n title: Test\n canonical: https://example.com\nsocial:\n thumbnail: t.png\n",
encoding="utf-8",
)
def _raise(*a, **k):
raise RuntimeError("boom")
monkeypatch.setattr(webui.subprocess, "run", _raise)
resp = client.post("/api/build")
assert resp.status_code == 500
assert "boom" in resp.get_json()["message"]
def test_download_output_zip(client, app_env):
output_dir = app_env / "output"
output_dir.mkdir()
(output_dir / "index.html").write_text("<html></html>", encoding="utf-8")
resp = client.post("/download-output-zip")
assert resp.status_code == 200
zip_bytes = io.BytesIO(resp.data)
with zipfile.ZipFile(zip_bytes) as zf:
assert "index.html" in zf.namelist()
assert not (app_env / "site_output.zip").exists()