877 lines
38 KiB
Python
877 lines
38 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 = [SITE / route[1] for route in pages.ROUTES] + [SITE / "404.html"]
|
|
GTIN_HTML = SITE / "gtin" / "index.html"
|
|
GTIN_IMPRESSUM = SITE / "gtin" / "impressum" / "index.html"
|
|
GTIN_DATENSCHUTZ = SITE / "gtin" / "datenschutz" / "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"<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",
|
|
"gtin/index.html",
|
|
"gtin/impressum/index.html",
|
|
"gtin/datenschutz/index.html",
|
|
"gtin/aktualisieren/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('<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 (
|
|
"/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"<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"))
|
|
|
|
def test_gtin_tool_is_public_but_unlisted(self):
|
|
text = read(GTIN_HTML)
|
|
self.assertIn("GTIN Generator", text)
|
|
self.assertIn('<link rel="canonical" href="https://desfoto.de/gtin/" />', text)
|
|
self.assertIn('<meta name="robots" content="noindex, nofollow" />', text)
|
|
self.assertIn('href="/gtin/impressum/"', text)
|
|
self.assertIn('href="/gtin/datenschutz/"', text)
|
|
self.assertEqual(len(re.findall(r'<a href="/gtin/datenschutz/">', text)), 1)
|
|
self.assertNotIn('href="/impressum/"', text)
|
|
self.assertNotIn('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))
|
|
self.assertNotIn("GTIN", read(file))
|
|
|
|
def test_gtin_legal_pages_are_standalone_and_unlisted(self):
|
|
impressum = read(GTIN_IMPRESSUM)
|
|
datenschutz = read(GTIN_DATENSCHUTZ)
|
|
for path, text in (
|
|
("/gtin/impressum/", impressum),
|
|
("/gtin/datenschutz/", datenschutz),
|
|
):
|
|
with self.subTest(path=path):
|
|
self.assertIn(f'<link rel="canonical" href="https://desfoto.de{path}" />', text)
|
|
self.assertIn('<meta name="robots" content="noindex, nofollow" />', text)
|
|
self.assertNotIn('href="/impressum/"', text)
|
|
self.assertNotIn('href="/datenschutz/"', text)
|
|
self.assertNotIn('href="/"', text)
|
|
self.assertNotIn("assets/", text)
|
|
self.assertIn('href="/gtin/icons/favicon.svg"', text)
|
|
for value in (
|
|
LEGAL["business"], LEGAL["owner"], LEGAL["street"], LEGAL["city"],
|
|
CONTACT["email"], CONTACT["phone"], "§ 5 DDG", LEGAL["w_id"],
|
|
):
|
|
with self.subTest(impressum=value):
|
|
self.assertIn(value, visible_text(impressum))
|
|
for value in (
|
|
LEGAL["owner"], CONTACT["email"], "bis zu 20", "Cache API",
|
|
"nicht an einen Server übertragen", "keine Cookies", "Art. 6 Abs. 1 lit. f DSGVO",
|
|
LEGAL["authority"],
|
|
):
|
|
with self.subTest(datenschutz=value):
|
|
self.assertIn(value, visible_text(datenschutz))
|
|
self.assertIn('href="/gtin/datenschutz/"', impressum)
|
|
self.assertIn('href="/gtin/impressum/"', datenschutz)
|
|
|
|
def test_main_legal_pages_do_not_name_the_gtin_tool(self):
|
|
for path in (SITE / "impressum" / "index.html", SITE / "datenschutz" / "index.html"):
|
|
with self.subTest(path=str(path.relative_to(SITE))):
|
|
self.assertNotIn("GTIN", visible_text(read(path)))
|
|
|
|
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("</section>", start)
|
|
block = home[start:end]
|
|
self.assertEqual(4, block.count('<article class="card reveal">'))
|
|
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("<footer"):]
|
|
self.assertNotIn("Denny Schulz Fotografie", footer)
|
|
|
|
def test_legal_entity_only_survives_in_legal_pages_and_structured_data(self):
|
|
"""„Denny Schulz Fotografie" ist die Rechtsperson, nicht die Marke.
|
|
|
|
Sichtbar darf sie nur auf Impressum und Datenschutz auftauchen; auf allen
|
|
anderen Seiten ist ausschließlich das unsichtbare ``legalName`` im
|
|
JSON-LD erlaubt.
|
|
"""
|
|
for file in HTML_FILES:
|
|
if file.parent.name in ("impressum", "datenschutz"):
|
|
continue
|
|
remainder = read(file).replace('"legalName":"Denny Schulz Fotografie"', "")
|
|
with self.subTest(file=str(file.relative_to(SITE))):
|
|
self.assertNotIn("Denny Schulz Fotografie", remainder)
|
|
|
|
def test_brand_is_written_lowercase_only(self):
|
|
"""Der Betreiber schreibt die Marke klein: ``desfoto``, nie ``DESFOTO``."""
|
|
for file in HTML_FILES:
|
|
with self.subTest(file=str(file.relative_to(SITE))):
|
|
self.assertNotIn("DESFOTO", read(file))
|
|
|
|
def test_built_pages_carry_no_trailing_whitespace(self):
|
|
"""Zeilen aus Leerzeichen lehnt das Release-Gateway (``git diff --check``) ab.
|
|
|
|
Eine bedingte Zeile in einer f-String-Vorlage erzeugt genau das: leer
|
|
eingesetzt bleibt die Einrückung stehen. Der Test hält die Ausgabe sauber,
|
|
damit die Freigabe nicht erst am Gateway scheitert.
|
|
"""
|
|
offenders = [
|
|
f"{file.relative_to(SITE)}:{number}"
|
|
for file in HTML_FILES
|
|
for number, line in enumerate(read(file).splitlines(), start=1)
|
|
if line != line.rstrip()
|
|
]
|
|
self.assertEqual([], offenders)
|
|
|
|
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_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("</section>", start)
|
|
block = home[start:end]
|
|
self.assertEqual(4, block.count('class="link-arrow"'))
|
|
self.assertNotIn("<a class=\"card", block)
|
|
|
|
def test_nginx_redirects_the_renamed_photo_urls(self):
|
|
conf = read(ROOT / "nginx.conf")
|
|
for line in (
|
|
"location = /shootings { return 301 /fotografie/; }",
|
|
"location = /shootings/ { return 301 /fotografie/; }",
|
|
"location = /shootings.html { return 301 /fotografie/; }",
|
|
"location = /fotografie.html { return 301 /fotografie/; }",
|
|
"location = /familien-und-paare { return 301 /familie/; }",
|
|
"location = /familien-und-paare/ { return 301 /familie/; }",
|
|
"location = /familien-und-paare.html { return 301 /familie/; }",
|
|
):
|
|
with self.subTest(line=line):
|
|
self.assertIn(line, conf)
|
|
# The old umbrella must not win over the new one.
|
|
self.assertNotIn("return 301 /shootings/;", conf)
|
|
# /fotografie/index.html is canonicalised to /fotografie/ by the
|
|
# server-level index rule instead of bouncing back to itself.
|
|
self.assertIn(r"if ($request_uri ~ ^/(.*/)?index\.html(\?.*)?$)", conf)
|
|
|
|
def test_retired_routes_and_brand_bleed_assets_are_gone(self):
|
|
self.assertFalse((SITE / "shootings").exists())
|
|
self.assertFalse((SITE / "familien-und-paare").exists())
|
|
self.assertFalse((SITE / "assets" / "img" / "og-shootings.jpg").exists())
|
|
self.assertFalse((SITE / "assets" / "img" / "og-familien.jpg").exists())
|
|
manifest = json.loads(read(SRC / "images.json"))
|
|
slugs = {entry["slug"] for entry in manifest["images"]}
|
|
for slug in (
|
|
# Studiopanorama with a visible "Denny Schulz Fotograf" business card.
|
|
"studio-setup",
|
|
# Romantic couple imagery: advertised on dennyschulz.de only.
|
|
"people-joy",
|
|
"people-kiss",
|
|
"portrait-couple",
|
|
"couple-park",
|
|
"couple-backlight-kiss",
|
|
"couple-forest-walk",
|
|
"couple-sun-silhouette",
|
|
"couple-laugh-walk",
|
|
# Retired from an earlier build.
|
|
"people-forest",
|
|
"people-veil",
|
|
"people-walk",
|
|
"hero-studio",
|
|
"travel-blossom",
|
|
"travel-japan",
|
|
"travel-norway-coast",
|
|
"travel-norway-lake",
|
|
"travel-norway-pano",
|
|
"travel-norway-valley",
|
|
):
|
|
with self.subTest(slug=slug):
|
|
self.assertNotIn(slug, slugs)
|
|
for image in (SITE / "assets" / "img").glob(f"{slug}-*.webp"):
|
|
self.fail(f"stale image built: {image.name}")
|
|
for file in HTML_FILES:
|
|
self.assertNotIn(slug, read(file))
|
|
|
|
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_couple_photography_assets_are_gone_entirely(self):
|
|
"""Paare gehören zu dennyschulz.de — auch als Bild dürfen sie nicht mitlaufen.
|
|
|
|
``portrait-natural``, ``event-motion`` und ``event-dance`` zeigen Brautpaare
|
|
und sind deshalb aus dem Bildbestand entfernt, nicht nur von den Seiten.
|
|
"""
|
|
manifest = json.loads(read(SRC / "images.json"))
|
|
slugs = {entry["slug"] for entry in manifest["images"]}
|
|
for slug in ("portrait-natural", "event-motion", "event-dance"):
|
|
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_routes_only(self):
|
|
sitemap = read(SITE / "sitemap.xml")
|
|
for route in ("fotografie", "familie", "musik-und-buehne"):
|
|
with self.subTest(route=route):
|
|
self.assertIn(f"<loc>https://desfoto.de/{route}/</loc>", sitemap)
|
|
for retired in ("shootings", "familien-und-paare"):
|
|
with self.subTest(route=retired):
|
|
self.assertNotIn(f"<loc>https://desfoto.de/{retired}/</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", "fotografie/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",
|
|
"fotografie/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",
|
|
),
|
|
"familie": (
|
|
"free-lake-person",
|
|
"free-portrait-light",
|
|
"portrait-marina-snow",
|
|
"free-modern-portrait",
|
|
"free-woman-coast",
|
|
"portrait-marina-sunset",
|
|
),
|
|
"musik-und-buehne": (
|
|
"music-guitar-silhouette",
|
|
"band-extinct-guitar",
|
|
"music-neck",
|
|
"music-banner",
|
|
"music-drums",
|
|
),
|
|
"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_musik_und_buehne_page_shows_the_real_cases(self):
|
|
page = read(SITE / "musik-und-buehne" / "index.html")
|
|
for needle in ("Thjódrörir", "Extinct", "Sagenbringer"):
|
|
with self.subTest(needle=needle):
|
|
self.assertIn(needle, page)
|
|
for step in ("Konzept", "Dreh", "Schnitt", "Farbkorrektur"):
|
|
with self.subTest(step=step):
|
|
self.assertIn(f"<h3>{step}</h3>", 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("<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)
|
|
|
|
def test_footer_grid_keeps_a_readable_brand_column(self):
|
|
"""Regression: the brand track was `minmax(0,1.5fr)`, so the 9rem auto-fit
|
|
link columns squeezed it to a few pixels between roughly 390px and 960px and
|
|
the "Kontakt"/"Netzwerk" headings overlapped the logo and the claim. The
|
|
brand column must keep a real minimum and the narrow layout must give the
|
|
brand a full-width row."""
|
|
css = read(SITE / "assets" / "site.css")
|
|
grid = re.search(r"\.footer__grid\{[^}]*\}", css)
|
|
self.assertIsNotNone(grid, "footer grid rule missing")
|
|
self.assertIn("minmax(15rem,1.5fr)", grid.group(0))
|
|
self.assertNotIn("minmax(0,1.5fr)", grid.group(0))
|
|
narrow = re.search(r"@media \(max-width:52rem\)\{(?:(?!\n\}).)*?\n\}", css, re.S)
|
|
self.assertIsNotNone(narrow, "52rem breakpoint missing")
|
|
self.assertIn(".footer__brand{grid-column:1/-1}", narrow.group(0))
|
|
self.assertIn(".footer__grid{grid-template-columns:repeat(2,minmax(0,1fr))}", narrow.group(0))
|
|
# Long unbreakable tokens (the e-mail address) must wrap instead of spilling
|
|
# out of a narrow column.
|
|
self.assertIn(".footer__links{display:grid;gap:.5rem;overflow-wrap:anywhere}", css)
|
|
|
|
|
|
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_routes(self):
|
|
verify = read(ROOT / ".ocauto" / "verify")
|
|
for check in (
|
|
'check_redirect "$base/shootings/" "$base/fotografie/"',
|
|
'check_redirect "$base/familien-und-paare/" "$base/familie/"',
|
|
'check_contains "$base/fotografie/"',
|
|
'check_contains "$base/musik-und-buehne/"',
|
|
):
|
|
with self.subTest(check=check):
|
|
self.assertIn(check, verify)
|
|
self.assertNotIn("/shootings/ /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)
|