Files
desfoto/tests/test_site.py
desfoto automation 8ffeea6731 feat: expand portfolios, drop wedding crowd photos, offer studio on site
- 28 more of the operator's own public images (42 -> 67), alts from his captions
- remove group-feier, group-outdoor, group-posiert (wedding guests visible)
- studio section now offers Studio Neumuenster and Mobiles Studio
- deploy: --force-recreate on release and rollback, plus in-container content probe
- verify: DESFOTO_BASE dry-run, redirect, asset and privacy checks
- tests: 57 OK (3 new content guards, 2 deploy/verify regression guards)
2026-09-19 17:37:49 +02:00

622 lines
26 KiB
Python

#!/usr/bin/env python3
"""Deterministic structural tests for the committed desfoto.de build in site/.
Run after `python3 scripts/build-site.py`:
python3 -m unittest discover -s tests -v
The tests only read generated files, so they are fast and side-effect free.
"""
from __future__ import annotations
import json
import re
import sys
import unittest
from html import unescape
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SITE = ROOT / "site"
SRC = ROOT / "src"
sys.path.insert(0, str(SRC))
import pages # noqa: E402
from content import CONTACT, LEGAL, SITE as META # noqa: E402
ROUTE_PATHS = [route[0] for route in pages.ROUTES]
HTML_FILES = sorted(SITE.rglob("*.html"))
def read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def visible_text(html_text: str) -> str:
body = re.search(r"<body\b.*</body>", html_text, re.S)
chunk = body.group(0) if body else html_text
chunk = re.sub(r"<(script|style)\b.*?</\1>", " ", chunk, flags=re.S)
return unescape(re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", chunk))).strip()
class BuildLayout(unittest.TestCase):
def test_every_route_is_built(self):
for route in pages.ROUTES:
_path, rel = route[0], route[1]
with self.subTest(route=route[0]):
self.assertTrue((SITE / rel).is_file(), f"missing {rel}")
def test_no_stray_html_files(self):
expected = {route[1] for route in pages.ROUTES} | {"404.html"}
actual = {str(f.relative_to(SITE)) for f in HTML_FILES}
self.assertEqual(expected, actual)
def test_site_files_are_world_readable(self):
"""The nginx worker is not root: 0600 build output would 403 in production."""
for path in SITE.rglob("*"):
if path.is_dir():
continue
with self.subTest(asset=str(path.relative_to(SITE))):
self.assertTrue(path.stat().st_mode & 0o044, f"{path} is not world readable")
def test_meta_files_exist(self):
for rel in (
"sitemap.xml",
"robots.txt",
"site.webmanifest",
"favicon.svg",
".well-known/security.txt",
"assets/site.css",
"assets/site.js",
"assets/fonts.css",
):
with self.subTest(rel=rel):
self.assertTrue((SITE / rel).is_file(), f"missing {rel}")
class HeadTags(unittest.TestCase):
def test_titles_descriptions_and_canonicals(self):
for path in ROUTE_PATHS:
rel = dict((r[0], r[1]) for r in pages.ROUTES)[path]
with self.subTest(route=path):
text = read(SITE / rel)
self.assertIn('<html lang="de">', text)
title = re.search(r"<title>([^<]+)</title>", text)
self.assertIsNotNone(title)
self.assertTrue(title.group(1).strip())
desc = re.search(r'<meta name="description" content="([^"]*)"', text)
self.assertIsNotNone(desc)
self.assertGreaterEqual(len(desc.group(1)), 60)
self.assertIn(f'<link rel="canonical" href="{META["url"]}{path}">', text)
self.assertIn('<meta name="robots" content="index,follow', text)
self.assertIn('<meta property="og:image" content="https://desfoto.de/assets/img/og-', text)
self.assertEqual(text.count('class="masthead"'), 1)
self.assertEqual(text.count('id="main"'), 1)
def test_not_found_page_is_not_indexable(self):
text = read(SITE / "404.html")
self.assertIn('<meta name="robots" content="noindex,follow">', text)
self.assertNotIn('rel="canonical"', text)
def test_single_h1_per_page(self):
for path in ROUTE_PATHS:
rel = dict((r[0], r[1]) for r in pages.ROUTES)[path]
with self.subTest(route=path):
self.assertEqual(read(SITE / rel).count("<h1"), 1)
def test_structured_data_is_valid_json(self):
for path in ROUTE_PATHS:
rel = dict((r[0], r[1]) for r in pages.ROUTES)[path]
for block in re.findall(
r'<script type="application/ld\+json">(.*?)</script>',
read(SITE / rel),
re.S,
):
with self.subTest(route=path):
json.loads(block)
class ImagesAndAssets(unittest.TestCase):
def test_every_img_has_alt(self):
for file in HTML_FILES:
for tag in re.findall(r"<img\b[^>]*>", read(file)):
with self.subTest(file=file.name):
self.assertIn('alt="', tag)
def test_wordpress_style_external_assets_absent(self):
"""No privacy page promise may be broken: nothing is loaded cross-origin."""
allowed = {"https://www.youtube-nocookie.com"}
for file in HTML_FILES + [SITE / "assets" / "site.css", SITE / "assets" / "fonts.css"]:
text = read(file)
candidates = list(re.findall(r'(?:src|srcset|imagesrcset|poster)="([^"]+)"', text))
candidates += list(re.findall(r"url\(([^)]*)\)", text))
for match in candidates:
for url in re.findall(r"(https?://[^\s,)'\"]+)", match):
with self.subTest(file=file.name, url=url):
self.assertIn(url.split("/embed")[0], allowed | {META["url"]})
def test_referenced_images_exist(self):
for file in HTML_FILES:
text = read(file)
for candidate in set(re.findall(r"/assets/img/([A-Za-z0-9._-]+)", text)):
with self.subTest(file=file.name, asset=candidate):
self.assertTrue((SITE / "assets" / "img" / candidate).is_file())
def test_no_hero_image_without_dimensions(self):
for file in HTML_FILES:
for tag in re.findall(r"<img\b[^>]*>", read(file)):
with self.subTest(file=file.name):
self.assertIn("width=", tag)
self.assertIn("height=", tag)
class PrivacyAndLegal(unittest.TestCase):
def test_no_cookies_analytics_or_third_party_scripts(self):
for file in HTML_FILES:
text = read(file).lower()
with self.subTest(file=file.name):
for forbidden in ("google-analytics", "googletagmanager", "gtag(", "document.cookie", "fonts.googleapis"):
self.assertNotIn(forbidden, text)
def test_nginx_writes_no_access_log(self):
conf = read(ROOT / "nginx.conf")
self.assertIn("access_log off;", conf)
self.assertIn("error_log /dev/null crit;", conf)
self.assertNotIn("/var/log/nginx", conf)
def test_nginx_sets_the_documented_security_headers(self):
conf = read(ROOT / "nginx.conf")
for header in (
"X-Content-Type-Options",
"Referrer-Policy",
"X-Frame-Options",
"Permissions-Policy",
"Strict-Transport-Security",
"Content-Security-Policy",
):
with self.subTest(header=header):
self.assertIn(f"add_header {header}", conf)
self.assertIn("default-src 'self'", conf)
self.assertIn("frame-src https://www.youtube-nocookie.com", conf)
self.assertIn("form-action 'self' mailto:", conf)
def test_styles_and_scripts_revalidate(self):
"""site.css/site.js are not content-hashed: they must be revalidated."""
conf = read(ROOT / "nginx.conf")
cache_rule = re.search(r'~\^/assets/site\\\.\(css\|js\)\$\s+"([^"]+)"', conf)
self.assertIsNotNone(cache_rule)
self.assertIn("max-age=0", cache_rule.group(1))
self.assertIn("must-revalidate", cache_rule.group(1))
def test_index_html_redirect_handles_query_strings(self):
conf = read(ROOT / "nginx.conf")
self.assertIn(r"^/(.*/)?index\.html(\?.*)?$", conf)
def test_impressum_has_required_provider_data(self):
text = visible_text(read(SITE / "impressum" / "index.html"))
for value in (
LEGAL["owner"],
LEGAL["street"],
LEGAL["city"],
CONTACT["email"],
CONTACT["phone"],
"§ 5 DDG",
"§ 19 UStG",
LEGAL["w_id"],
"§ 18 Abs. 2 MStV",
):
with self.subTest(value=value):
self.assertIn(value, text)
def test_datenschutz_covers_the_actual_data_flows(self):
text = visible_text(read(SITE / "datenschutz" / "index.html"))
for needle in (
"keine Zugriffsprotokolle",
"keine Cookies",
"youtube-nocookie.com",
"Art. 6 Abs. 1 lit. f DSGVO",
LEGAL["authority"],
"Art. 22 DSGVO",
):
with self.subTest(needle=needle):
self.assertIn(needle, text)
def test_datenschutz_matches_the_log_free_server_configuration(self):
"""The page must describe `error_log /dev/null`, not a retention period."""
text = visible_text(read(SITE / "datenschutz" / "index.html"))
self.assertIn("Fehlerprotokoll wird nicht geschrieben", text)
self.assertNotIn("14 Tagen gelöscht", text)
conf = read(ROOT / "nginx.conf")
self.assertIn("error_log /dev/null crit;", conf)
def test_site_javascript_is_local_and_stateless(self):
js = read(SITE / "assets" / "site.js")
for forbidden in (
"document.cookie",
"localStorage",
"sessionStorage",
"indexedDB",
"navigator.sendBeacon",
"XMLHttpRequest",
"fetch(",
"WebSocket",
):
with self.subTest(forbidden=forbidden):
self.assertNotIn(forbidden, js)
def test_contact_form_sends_nothing_by_default(self):
text = read(SITE / "kontakt" / "index.html")
self.assertIn("data-contact-form", text)
self.assertIn('action="mailto:info@dennyschulz.de"', text)
self.assertNotIn('method="post" action="/', text)
self.assertIn("sendet nichts an einen Server", visible_text(text))
def test_https_links_and_no_plain_http(self):
for file in HTML_FILES:
with self.subTest(file=file.name):
self.assertNotIn("http://desfoto.de", read(file))
class Content(unittest.TestCase):
def test_all_requested_services_are_reachable(self):
home = read(SITE / "index.html")
for href in (
"/shootings/",
"/businessfotografie/",
"/familien-und-paare/",
"/minishootings/",
"/projekte/",
"/video/",
"/social-media/",
):
with self.subTest(href=href):
self.assertIn(f'href="{href}"', home)
def test_pro_bono_work_is_offered(self):
projekte = read(SITE / "projekte" / "index.html")
self.assertIn("Pro bono", projekte)
self.assertIn("Pro-bono-Anfrage senden", projekte)
kontakt = read(SITE / "kontakt" / "index.html")
self.assertIn("Pro-bono-Anfrage", kontakt)
def test_music_video_is_embedded_and_only_after_a_click(self):
text = read(SITE / "video" / "index.html")
self.assertIn("NWWFTf7l8g0", text)
self.assertIn("youtube-nocookie.com", read(SITE / "assets" / "site.js"))
self.assertNotIn("youtube.com/embed", text)
def test_no_placeholder_or_unfinished_text(self):
markers = ("lorem ipsum", "todo", "tbd", "xxx", "platzhalter", "coming soon")
for file in HTML_FILES:
lower = read(file).lower()
for marker in markers:
with self.subTest(file=file.name, marker=marker):
self.assertNotIn(marker, lower)
def test_footer_on_every_page(self):
for file in HTML_FILES:
text = read(file)
with self.subTest(file=file.name):
self.assertIn('href="/impressum/"', text)
self.assertIn('href="/datenschutz/"', text)
self.assertIn(CONTACT["email"], text)
def test_sitemap_matches_routes(self):
sitemap = read(SITE / "sitemap.xml")
for path in ROUTE_PATHS:
with self.subTest(route=path):
self.assertIn(f"<loc>{META['url']}{path}</loc>", sitemap)
self.assertNotIn("404", sitemap)
def test_robots_allows_crawling(self):
self.assertIn("User-agent: *", read(SITE / "robots.txt"))
self.assertIn(f"Sitemap: {META['url']}/sitemap.xml", read(SITE / "robots.txt"))
class Redesign(unittest.TestCase):
"""The 2026 redesign: Shootings umbrella, real portfolio, logo, motion layer."""
def test_nav_uses_shootings_as_the_photo_umbrella(self):
for file in HTML_FILES:
text = read(file)
with self.subTest(file=file.name):
self.assertIn('href="/shootings/"', text)
self.assertNotIn('href="/fotografie/', text)
def test_all_four_shootings_are_grouped_under_the_umbrella(self):
shootings = read(SITE / "shootings" / "index.html")
for href in (
"/businessfotografie/",
"/portrait-und-model/",
"/familien-und-paare/",
"/minishootings/",
):
with self.subTest(href=href):
self.assertIn(f'href="{href}"', shootings)
def test_photo_and_video_are_separate_but_both_on_the_homepage(self):
home = read(SITE / "index.html")
self.assertEqual(home.count('<article class="pillar spot reveal">'), 2)
self.assertIn('<a class="pillar__go" href="/shootings/">Shootings ansehen', home)
self.assertIn('<a class="pillar__go" href="/video/">Videoprojekte ansehen', home)
def test_links_are_never_nested(self):
"""Nested <a> is invalid and the parser splits the surrounding layout."""
import html.parser
class AnchorGuard(html.parser.HTMLParser):
def __init__(self):
super().__init__()
self.depth = 0
self.problems: list[str] = []
self.file = ""
def handle_starttag(self, tag, attrs):
if tag == "a":
self.depth += 1
if self.depth > 1:
self.problems.append(f"{self.file}: nested <a>")
def handle_endtag(self, tag):
if tag == "a" and self.depth:
self.depth -= 1
for file in HTML_FILES:
guard = AnchorGuard()
guard.file = file.name
guard.feed(read(file))
with self.subTest(file=file.name):
self.assertEqual([], guard.problems)
self.assertEqual(0, guard.depth, "unbalanced <a>")
def test_pillar_grid_has_exactly_two_children(self):
"""Regression: the two pillars must stay two grid children."""
home = read(SITE / "index.html")
start = home.index('<div class="pillars"')
end = home.index("</section>", start)
self.assertEqual(2, home[start:end].count('<article class="pillar spot reveal">'))
def test_nginx_redirects_the_retired_fotografie_url(self):
conf = read(ROOT / "nginx.conf")
for line in (
"location = /fotografie { return 301 /shootings/; }",
"location = /fotografie/ { return 301 /shootings/; }",
"location = /fotografie.html { return 301 /shootings/; }",
):
with self.subTest(line=line):
self.assertIn(line, conf)
# /fotografie/index.html is canonicalised to /fotografie/ by the
# server-level index rule and reaches /shootings/ on the second hop.
self.assertIn(r"if ($request_uri ~ ^/(.*/)?index\.html(\?.*)?$)", conf)
def test_retired_fotografie_build_output_is_gone(self):
self.assertFalse((SITE / "fotografie").exists())
self.assertFalse((SITE / "assets" / "img" / "og-fotografie.jpg").exists())
for slug in (
"people-forest",
"people-veil",
"people-walk",
"hero-studio",
"travel-blossom",
"travel-japan",
"travel-norway-coast",
"travel-norway-lake",
"travel-norway-pano",
"travel-norway-valley",
):
for image in (SITE / "assets" / "img").glob(f"{slug}-*.webp"):
self.fail(f"stale image built: {image.name}")
def test_wedding_crowd_photos_are_not_shipped(self):
"""The operator asked to drop the wedding photos showing the guests."""
manifest = json.loads(read(SRC / "images.json"))
slugs = {entry["slug"] for entry in manifest["images"]}
for slug in ("group-feier", "group-outdoor", "group-posiert"):
with self.subTest(slug=slug):
self.assertNotIn(slug, slugs)
self.assertEqual([], list((SITE / "assets" / "img").glob(f"{slug}-*.webp")))
for file in HTML_FILES:
self.assertNotIn(slug, read(file))
def test_sitemap_lists_the_new_route_only(self):
sitemap = read(SITE / "sitemap.xml")
self.assertIn("<loc>https://desfoto.de/shootings/</loc>", sitemap)
self.assertNotIn("<loc>https://desfoto.de/fotografie/</loc>", sitemap)
def test_operator_logo_is_the_brand_mark(self):
self.assertTrue((SITE / "assets" / "img" / "logo-160.png").is_file())
for file in HTML_FILES:
with self.subTest(file=file.name):
self.assertIn(
'<span class="brand__mark"><img src="/assets/img/logo-160.png"',
read(file),
)
self.assertIn("data:image/png;base64,", read(SITE / "favicon.svg"))
def test_operator_logo_keeps_its_square_geometry(self):
"""The derived icons are square resizes; a non-square logo would crop."""
from PIL import Image
source = Image.open(ROOT / "assets-src" / "brand" / "logo.png")
self.assertEqual(source.size[0], source.size[1], "the original logo must stay square")
for name, size in (
("logo-160.png", 160),
("apple-touch-icon.png", 180),
("favicon-32.png", 32),
("favicon-16.png", 16),
):
with self.subTest(name=name):
self.assertEqual((size, size), Image.open(SITE / "assets" / "img" / name).size)
def test_footer_links_the_operators_other_projects(self):
for file in HTML_FILES:
text = read(file)
with self.subTest(file=file.name):
for url in ("https://www.dennyschulz.de/", "https://dennyapp.de/"):
self.assertIn(f'href="{url}"', text)
self.assertIn('target="_blank" rel="noopener"', text)
def test_mobile_studio_is_described_where_photo_work_is_sold(self):
needles = ("Mobiles Studio", "Autarke Blitzanlage", "autarker Blitzanlage")
for rel in ("index.html", "shootings/index.html", "businessfotografie/index.html"):
text = read(SITE / rel)
for needle in needles:
with self.subTest(file=rel, needle=needle):
self.assertIn(needle, text)
def test_studio_in_neumuenster_is_offered_alongside_the_mobile_studio(self):
"""Projects can also be photographed in the operator's own studio on site."""
for rel in (
"index.html",
"shootings/index.html",
"businessfotografie/index.html",
"ueber/index.html",
):
text = read(SITE / rel)
for needle in ("Studio Neumünster", "Studio in Neumünster"):
with self.subTest(file=rel, needle=needle):
self.assertIn(needle, text)
def test_portfolio_expansion_covers_more_of_the_public_work(self):
pages = {
"projekte": (
"music-dreadlocks",
"music-horns",
"band-extinct-group",
"free-castle-lake",
"free-swans",
"free-sparrow",
"free-dogs-forest",
),
"familien-und-paare": (
"couple-park",
"couple-backlight-kiss",
"couple-forest-walk",
"couple-sun-silhouette",
"couple-laugh-walk",
"portrait-marina-snow",
),
"portrait-und-model": ("editorial-studio-side", "editorial-balance", "editorial-leap"),
"businessfotografie": (
"business-hotel-window",
"business-hotel-orange",
"business-hotel-tea",
"business-hotel-laugh",
),
}
for rel, slugs in pages.items():
text = read(SITE / f"{rel}/index.html")
for slug in slugs:
with self.subTest(page=rel, slug=slug):
self.assertIn(f"/assets/img/{slug}-1200.webp", text)
def test_music_and_free_work_portfolio_is_present(self):
projekte = read(SITE / "projekte" / "index.html")
video = read(SITE / "video" / "index.html")
self.assertIn("Musik", projekte)
for slug in ("music-vocal-blue", "band-sagenbringer", "free-fjord", "free-fox"):
with self.subTest(page="projekte", slug=slug):
self.assertIn(f"/assets/img/{slug}-1200.webp", projekte)
for slug in ("music-guitar-silhouette", "music-drums"):
with self.subTest(page="video", slug=slug):
self.assertIn(f"/assets/img/{slug}-1200.webp", video)
def test_invented_japan_content_is_absent(self):
for file in HTML_FILES:
text = read(file).lower()
with self.subTest(file=file.name):
for needle in ("japan", "kirschblüte", "travel-", "people-forest", "people-veil"):
self.assertNotIn(needle, text)
def test_animation_layer_is_reduced_motion_safe(self):
css = read(SITE / "assets" / "site.css")
self.assertIn("@media (prefers-reduced-motion:reduce)", css)
motion = css[css.index("@media (prefers-reduced-motion:reduce)") :]
for needle in (
".reveal,.reveal--mask{opacity:1",
"animation:none",
".scroll-cue{display:none}",
):
with self.subTest(needle=needle):
self.assertIn(needle, motion)
# The reduced-motion block must win over the motion definitions.
self.assertGreater(css.rindex("@media (prefers-reduced-motion:reduce)"), css.rindex("animation:hero-zoom"))
def test_every_page_has_a_noscript_reveal_fallback(self):
for file in HTML_FILES:
text = read(file)
with self.subTest(file=file.name):
self.assertIn("<noscript><style>", text)
self.assertIn(".reveal,.reveal--mask{opacity:1", text)
def test_scroll_animations_are_progressive_enhancement(self):
js = read(SITE / "assets" / "site.js")
for needle in ("prefers-reduced-motion", "IntersectionObserver", "data-stagger"):
with self.subTest(needle=needle):
self.assertIn(needle, js)
class Release(unittest.TestCase):
def test_deploy_removes_only_targeted_desfoto_labels(self):
deploy = read(ROOT / ".ocauto" / "deploy")
self.assertNotIn('"desfoto" not in line', deploy)
self.assertNotIn("sudo python3", deploy)
self.assertIn("traefik\\..*desfoto", deploy)
self.assertIn("stack-backup", deploy)
def test_deploy_validates_before_replacing_the_shared_stack(self):
deploy = read(ROOT / ".ocauto" / "deploy")
self.assertIn('config --quiet', deploy)
self.assertIn('mktemp "$STACK_DIR/', deploy)
self.assertIn('sudo mv "$stack_tmp" "$stack_file"', deploy)
def test_deploy_rebinds_the_site_tree_after_replacing_it(self):
"""Regression: `rm -rf site` + tar replaces the bind-mount inode.
A container that is merely running keeps the deleted directory mounted and
then serves an empty document root (every request 404), so both the release
path and the rollback path must recreate the container.
"""
deploy = read(ROOT / ".ocauto" / "deploy")
self.assertEqual(2, deploy.count("up -d --force-recreate --remove-orphans"))
self.assertIn("docker exec desfoto-web-1 wget -q --spider http://127.0.0.1/", deploy)
def test_verify_hook_covers_the_renamed_route(self):
verify = read(ROOT / ".ocauto" / "verify")
self.assertIn('check_redirect "$base/fotografie/" "$base/shootings/"', verify)
self.assertIn("""check_not_contains "$base/" 'href="/fotografie/'""", verify)
self.assertNotIn('/fotografie/ /businessfotografie/', verify)
self.assertNotIn("hero-studio-1200.webp", verify)
def test_deploy_hooks_are_executable(self):
import os
for name in ("qa", "deploy", "verify"):
with self.subTest(hook=name):
path = ROOT / ".ocauto" / name
self.assertTrue(path.is_file(), f"missing .ocauto/{name}")
self.assertTrue(os.access(path, os.X_OK), f".ocauto/{name} is not executable")
class Fonts(unittest.TestCase):
def test_fonts_are_self_hosted(self):
css = read(SITE / "assets" / "fonts.css")
self.assertIn("@font-face", css)
self.assertIn("font-display:swap", css)
urls = re.findall(r"url\('([^']+)'\)", css)
self.assertTrue(urls)
for url in urls:
with self.subTest(url=url):
self.assertTrue(url.startswith("/assets/fonts/"))
self.assertTrue((SITE / url.lstrip("/")).is_file())
def test_licences_shipped(self):
for name in ("font-fraunces-OFL.txt", "font-manrope-OFL.txt"):
with self.subTest(name=name):
self.assertTrue((ROOT / "docs" / name).is_file())
if __name__ == "__main__":
unittest.main(verbosity=2)