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
CI / build-and-scan (push) Successful in 1m14s
Reviewed-on: #29
This commit was merged in pull request #29.
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
|
||||
!.sh
|
||||
!.gitignore
|
||||
!.gitea
|
||||
!.gitea/**
|
||||
output/
|
||||
__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
|
||||
|
||||
@@ -7,7 +7,7 @@ RUN apk add --no-cache gcc musl-dev jpeg-dev zlib-dev
|
||||
COPY requirements.txt .
|
||||
RUN pip wheel --no-cache-dir --wheel-dir=/wheels -r requirements.txt
|
||||
|
||||
FROM python:3.13-alpine
|
||||
FROM python:3.13.15-alpine AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -20,4 +20,13 @@ COPY ./config /app/default
|
||||
COPY ./docker/.sh/entrypoint.sh /app/entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
FROM base AS test
|
||||
|
||||
COPY requirements.txt requirements-dev.txt pytest.ini /app/
|
||||
RUN pip install --no-cache-dir -r requirements-dev.txt
|
||||
COPY ./tests/ ./tests/
|
||||
COPY ./demo/ ./demo/
|
||||
|
||||
FROM base
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
@@ -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