#!/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"
", 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('', text)
title = re.search(r"([^<]+)", text)
self.assertIsNotNone(title)
self.assertTrue(title.group(1).strip())
desc = re.search(r'', text)
self.assertIn('', 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("(.*?)',
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"
]*>", 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"
]*>", 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"{META['url']}{path}", 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(''), 2)
self.assertIn('Shootings ansehen', home)
self.assertIn('Videoprojekte ansehen', home)
def test_links_are_never_nested(self):
"""Nested 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 ")
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 ")
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('", start)
self.assertEqual(2, home[start:end].count('
'))
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_sitemap_lists_the_new_route_only(self):
sitemap = read(SITE / "sitemap.xml")
self.assertIn("https://desfoto.de/shootings/", sitemap)
self.assertNotIn("https://desfoto.de/fotografie/", 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(
'