Add pytest suite and Gitea Actions CI/CD pipeline
CI / build-and-scan (pull_request) Successful in 56s
CI / build-and-scan (pull_request) Successful in 56s
83 tests (94% coverage of src/py) covering the builder pipeline (gallery sync, HTML/CSS generation, image processing, full site build) and the Flask webui (routes, uploads, theme/font management). Dockerfile gains a `test` stage (pytest) between the wheel builder and the prod image, and pins the alpine base to a full patch tag so Renovate can classify updates. CI workflow builds, smoke-tests, runs the suite, scans with Trivy, and publishes/releases on merge to main, following the same pipeline already running on adguard-cidre.
This commit is contained in:
@@ -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"
|
||||||
@@ -2,5 +2,9 @@
|
|||||||
!.env
|
!.env
|
||||||
!.sh
|
!.sh
|
||||||
!.gitignore
|
!.gitignore
|
||||||
|
!.gitea
|
||||||
|
!.gitea/**
|
||||||
output/
|
output/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
htmlcov/
|
||||||
|
site_output.zip
|
||||||
+11
-2
@@ -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"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
pythonpath = .
|
||||||
|
testpaths = tests
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest==8.4.2
|
||||||
|
pytest-cov==7.0.0
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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">')
|
||||||
@@ -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"
|
||||||
@@ -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"?>')
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user