#!/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 = [SITE / route[1] for route in pages.ROUTES] + [SITE / "404.html"] GTIN_HTML = SITE / "gtin" / "index.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.*?", " ", 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", "gtin/index.html"} actual = {str(f.relative_to(SITE)) for f in SITE.rglob("*.html")} 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", "GTIN-Tool", "bis zu 20", "nicht an einen Server übertragen", "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 ( "/fotografie/", "/businessfotografie/", "/familie/", "/minishootings/", "/musik-und-buehne/", "/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")) def test_gtin_tool_is_public_but_unlisted(self): text = read(GTIN_HTML) self.assertIn("GTIN Generator", text) self.assertIn('', text) self.assertIn('', text) self.assertIn('href="/impressum/"', text) self.assertIn('href="/datenschutz/"', text) self.assertNotIn('href="/"', text) self.assertNotIn("/gtin/", read(SITE / "sitemap.xml")) for file in HTML_FILES: with self.subTest(file=str(file.relative_to(SITE))): self.assertNotIn("/gtin", read(file)) def test_gtin_assets_and_local_data_storage_are_scoped(self): html_text = read(GTIN_HTML) for asset in ("./css/app.css", "./js/app.js", "./manifest.webmanifest"): with self.subTest(asset=asset): self.assertTrue((SITE / "gtin" / asset.removeprefix("./")).is_file()) self.assertIn(asset, html_text) self.assertTrue((SITE / "gtin" / "sw.js").is_file()) self.assertFalse((SITE / "gtin" / "health").exists()) app_js = read(SITE / "gtin" / "js" / "app.js") self.assertIn("desfoto.gtin.verlauf.v1", app_js) self.assertIn("localStorage", app_js) self.assertIn(".register('./sw.js', { scope: './' })", app_js) self.assertNotIn("fetch(", app_js) self.assertNotIn("XMLHttpRequest", app_js) service_worker = read(SITE / "gtin" / "sw.js") self.assertIn("name.startsWith(CACHE_PREFIX)", service_worker) self.assertIn("if (!ziel.pathname.startsWith(SCOPE_PFAD)) return;", service_worker) self.assertNotIn("/impressum/", service_worker) def test_gtin_manifest_uses_the_web_app_manifest_mime_type(self): conf = read(ROOT / "nginx.conf") self.assertIn("location = /gtin/manifest.webmanifest", conf) self.assertIn("default_type application/manifest+json;", conf) class Redesign(unittest.TestCase): """The 2026 redesign: Arbeitsbereiche, real portfolio, logo, motion layer.""" def test_nav_uses_fotografie_as_the_photo_umbrella(self): for file in HTML_FILES: text = read(file) with self.subTest(file=file.name): self.assertIn('href="/fotografie/"', text) self.assertIn('href="/familie/"', text) self.assertNotIn('href="/shootings', text) self.assertNotIn('href="/familien-und-paare', text) def test_all_four_photo_schwerpunkte_are_grouped_under_the_umbrella(self): fotografie = read(SITE / "fotografie" / "index.html") for href in ( "/businessfotografie/", "/portrait-und-model/", "/familie/", "/minishootings/", ): with self.subTest(href=href): self.assertIn(f'href="{href}"', fotografie) def test_homepage_explains_the_four_arbeitsbereiche(self): home = read(SITE / "index.html") start = home.index("Arbeitsbereiche") end = home.index("", start) block = home[start:end] self.assertEqual(4, block.count('
')) for href in ("/fotografie/", "/video/", "/musik-und-buehne/", "/projekte/"): with self.subTest(href=href): self.assertIn(f'href="{href}"', block) for scope in ( "Menschen, Unternehmen, Produkte, Veranstaltungen.", "Unternehmen, Musik, Events, Social Content.", "Bands, Künstler, Konzerte, Musikvideo.", "Freie Arbeiten, Kooperationen, besondere Produktionen.", ): with self.subTest(scope=scope): self.assertIn(scope, block) def test_homepage_separates_bookable_work_from_own_projects(self): home = read(SITE / "index.html") self.assertIn("Was du bei mir buchen kannst", home) self.assertIn("Was ich außerdem mache", home) self.assertLess( home.index("Was du bei mir buchen kannst"), home.index("Was ich außerdem mache"), ) def test_couples_are_not_advertised_on_desfoto(self): """Paare/Engagement stay with dennyschulz.de; desfoto keeps single portraits.""" for file in HTML_FILES: text = read(file) with self.subTest(file=file.name): for needle in ("Paarshooting", "Paarportrait", "Verlobung", "Ja-Wort", "Brautpaar"): self.assertNotIn(needle, text) # Der einzige erlaubte Paar-Bezug ist der Hinweis auf den eigenen # Schwerpunkt auf dennyschulz.de im Footer. remainder = visible_text(text).replace("Hochzeiten und Paare", "") self.assertNotIn("Paar", remainder) def test_footer_carries_the_ownership_line_without_the_legal_entity(self): """The brand is tied to its owner in one unobtrusive footer sentence.""" for file in HTML_FILES: text = read(file) with self.subTest(file=file.name): self.assertIn("desfoto ist ein Angebot von Denny Schulz, Neumünster.", text) footer = text[text.index(" 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_every_area_card_links_to_its_area(self): """Each Arbeitsbereich card keeps its own link (no nested anchors).""" home = read(SITE / "index.html") start = home.index("Arbeitsbereiche") end = home.index("", start) block = home[start:end] self.assertEqual(4, block.count('class="link-arrow"')) self.assertNotIn("https://desfoto.de/{route}/", sitemap) for retired in ("shootings", "familien-und-paare"): with self.subTest(route=retired): self.assertNotIn(f"https://desfoto.de/{retired}/", 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( '{step}", page) self.assertIn("NWWFTf7l8g0", page) 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("