Files
desfoto/tests/test_site.py
2026-09-25 19:47:28 +02:00

875 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",
}
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)
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)