Translate the whole site to English: English routes with 301 redirects from the Dutch paths, English UI, docs, seeds and captions, one-time live content migration

This commit is contained in:
2026-10-05 11:25:41 +02:00
parent bbdecdc95b
commit ff547e1677
153 changed files with 1095 additions and 774 deletions
+10 -10
View File
@@ -3,7 +3,7 @@ JSON = {"Accept": "application/json"}
def _create(admin, **overrides):
payload = {"title": "Een echte notitie", "body": "Tekst met **nadruk** en `code`.", "topic": "rol", "status": "draft"}
payload = {"title": "A real note", "body": "Text with **emphasis** and `code`.", "topic": "roll", "status": "draft"}
payload.update(overrides)
response = admin.post("/admin/posts", json=payload, headers=JSON)
assert response.status_code == 200, response.text
@@ -11,24 +11,24 @@ def _create(admin, **overrides):
def test_create_draft_is_hidden_then_published(admin, anon):
data = _create(admin, title="Concept dat later live gaat")
data = _create(admin, title="Draft that goes live later")
assert anon.get(f"/posts/{data['slug']}").status_code == 404
assert admin.post(f"/admin/posts/{data['uid']}/publish", headers=JSON).status_code == 200
page = anon.get(f"/posts/{data['slug']}")
assert page.status_code == 200
assert "<strong>nadruk</strong>" in page.text
assert "<strong>emphasis</strong>" in page.text
def test_edit_post(admin, anon):
data = _create(admin, title="Te bewerken notitie", status="published")
response = admin.post(f"/admin/posts/{data['uid']}", json={"title": "Bewerkte notitie", "body": "Nieuwe tekst hier.", "topic": "standaard", "status": "published"}, headers=JSON)
data = _create(admin, title="Note to edit", status="published")
response = admin.post(f"/admin/posts/{data['uid']}", json={"title": "Edited note", "body": "New text right here.", "topic": "standard", "status": "published"}, headers=JSON)
assert response.status_code == 200, response.text
posts = anon.get("/standaard", headers=JSON).json()["posts"]
assert any(post["title"] == "Bewerkte notitie" for post in posts)
posts = anon.get("/standard", headers=JSON).json()["posts"]
assert any(post["title"] == "Edited note" for post in posts)
def test_delete_goes_to_trash(admin, anon):
data = _create(admin, title="Weg ermee notitie", status="published")
data = _create(admin, title="Gone with it note", status="published")
assert admin.post(f"/admin/posts/{data['uid']}/delete", headers=JSON).status_code == 200
assert anon.get(f"/posts/{data['slug']}").status_code == 404
trash = admin.get("/admin/trash", headers=JSON).json()["items"]
@@ -41,10 +41,10 @@ def test_validation_error(admin):
def test_html_form_post(admin):
response = admin.post("/admin/posts", data={"title": "Via formulier", "body": "Formuliertekst hier.", "topic": "rol", "status": "draft"})
response = admin.post("/admin/posts", data={"title": "Via the form", "body": "Form text right here.", "topic": "roll", "status": "draft"})
assert response.status_code in (302, 303)
def test_member_cannot_write(member):
response = member.post("/admin/posts", json={"title": "Niet toegestaan", "body": "Tekst hier.", "topic": "rol"}, headers=JSON)
response = member.post("/admin/posts", json={"title": "Not allowed", "body": "Text right here.", "topic": "roll"}, headers=JSON)
assert response.status_code == 403
+7 -7
View File
@@ -4,15 +4,15 @@
def test_home_is_molodetz(anon):
html = anon.get("/").text
assert "Molodetz" in html
for label in ("Rol", "Standaard", "Flyers", "Memes", "Mensen", "Binnen"):
for label in ("Roll", "Standard", "Flyers", "Memes", "People", "Join"):
assert f">{label}</a>" in html
assert "wa.me" not in html and "whatsapp" not in html.lower()
def test_unknown_page_is_404(anon):
response = anon.get("/bestaat-niet-xyz")
response = anon.get("/does-not-exist-xyz")
assert response.status_code == 404
assert anon.get("/bestaat-niet-xyz", headers={"Accept": "application/json"}).json()["error"]
assert anon.get("/does-not-exist-xyz", headers={"Accept": "application/json"}).json()["error"]
def _intro_section(html):
@@ -47,15 +47,15 @@ def test_intro_json_face(anon):
def test_admin_can_edit_intro(admin, anon):
original = admin.get("/admin/settings", headers={"Accept": "application/json"}).json()["settings"]["site_intro"]
try:
response = admin.post("/admin/settings", json={"site_intro": "Eerste regel.\n\nTweede alinea\nmet een regel erbij."}, headers={"Accept": "application/json"})
response = admin.post("/admin/settings", json={"site_intro": "First line.\n\nSecond paragraph\nwith an extra line."}, headers={"Accept": "application/json"})
assert response.status_code == 200
intro = _intro_section(anon.get("/").text)
assert intro.find("p").get_text() == "Eerste regel."
assert intro.find("p").get_text() == "First line."
assert intro.find_all("p")[1].find("br") is not None
form = admin.post("/admin/settings", data={"site_intro": "Via formulier.\r\n\r\nRegel een\r\nregel twee"})
form = admin.post("/admin/settings", data={"site_intro": "Via the form.\r\n\r\nLine one\r\nline two"})
assert form.status_code in (302, 303)
stored = admin.get("/admin/settings", headers={"Accept": "application/json"}).json()["settings"]["site_intro"]
assert stored == "Via formulier.\n\nRegel een\nregel twee"
assert stored == "Via the form.\n\nLine one\nline two"
finally:
admin.post("/admin/settings", json={"site_intro": original}, headers={"Accept": "application/json"})
assert _intro_section(anon.get("/").text).find("p").get_text(" ", strip=True).startswith("I wrote more code than you.")
+5 -5
View File
@@ -3,11 +3,11 @@ JSON = {"Accept": "application/json"}
def test_join_request_is_stored_and_visible_to_admin(anon, admin):
response = anon.post("/binnen", data={"name": "Schrijver Een", "contact": "schrijver@example.invalid", "repo_url": "https://example.com/repo", "message": "Ik wil meeschrijven.", "website": ""})
response = anon.post("/join", data={"name": "Writer One", "contact": "schrijver@example.invalid", "repo_url": "https://example.com/repo", "message": "I want to write along.", "website": ""})
assert response.status_code in (302, 303)
assert "ok=1" in response.headers["location"]
rows = admin.get("/admin/joins", headers=JSON).json()["requests"]
row = next(row for row in rows if row["name"] == "Schrijver Een")
row = next(row for row in rows if row["name"] == "Writer One")
assert row["contact"] == "schrijver@example.invalid"
assert row["repo_url"].startswith("https://example.com/repo")
status = admin.post(f"/admin/joins/{row['uid']}/status", json={"status": "contacted"}, headers=JSON)
@@ -15,19 +15,19 @@ def test_join_request_is_stored_and_visible_to_admin(anon, admin):
def test_join_json_api(anon):
response = anon.post("/binnen", json={"name": "Schrijver Twee", "contact": "@twee"}, headers=JSON)
response = anon.post("/join", json={"name": "Writer Two", "contact": "@twee"}, headers=JSON)
assert response.status_code == 200
assert response.json()["ok"] is True
def test_honeypot_is_not_stored(anon, admin):
anon.post("/binnen", data={"name": "Robot Bot", "contact": "bot@example.invalid", "website": "spam"})
anon.post("/join", data={"name": "Robot Bot", "contact": "bot@example.invalid", "website": "spam"})
rows = admin.get("/admin/joins", headers=JSON).json()["requests"]
assert not any(row["name"] == "Robot Bot" for row in rows)
def test_join_validation(anon):
response = anon.post("/binnen", json={"name": "x", "contact": ""}, headers=JSON)
response = anon.post("/join", json={"name": "x", "contact": ""}, headers=JSON)
assert response.status_code == 422
+57
View File
@@ -0,0 +1,57 @@
# retoor <retoor@molodetz.nl>
import re
import pytest
PAGES = ["/", "/roll", "/standard", "/flyers", "/memes", "/people", "/people/retoor", "/join", "/terms", "/privacy", "/auth/login", "/docs", "/docs/welcome", "/docs/api/content"]
DUTCH = ("Ik schrijf mee", "Mensen", "Binnen", "Voorwaarden", "Inloggen", "Meldingen", "Beheer", "Naar inhoud", "Nog niets", "Intro bewerken", "Een zes is", "Eenendertig", "Gezet in serif", "Verder lezen")
@pytest.mark.parametrize("path", PAGES)
def test_pages_are_english(anon, path):
html = anon.get(path).text
assert '<html lang="en">' in html
assert 'content="en_US"' in html
visible = re.sub(r"<script.*?</script>", "", html, flags=re.S)
for word in DUTCH:
assert word not in visible, (path, word)
def test_nav_uses_english_paths(anon):
html = anon.get("/").text
for href, label in (("/roll", "Roll"), ("/standard", "Standard"), ("/people", "People"), ("/join", "Join")):
assert f'href="{href}"' in html and f">{label}</a>" in html
for old in ('href="/rol"', 'href="/standaard"', 'href="/mensen"', 'href="/binnen"', 'href="/voorwaarden"'):
assert old not in html
assert ">I'm in</a>" in html or ">I&#39;m in</a>" in html
def test_admin_sees_edit_intro(admin):
html = admin.get("/").text
assert "Edit intro" in html
assert "Intro bewerken" not in html
def test_sitemap_lists_english_paths(anon):
xml = anon.get("/sitemap.xml").text
for path in ("/roll<", "/standard<", "/people<", "/join<", "/terms<", "/docs/welcome<"):
assert path in xml, path
for old in ("/rol<", "/mensen", "/binnen", "/voorwaarden", "/docs/welkom"):
assert old not in xml, old
def test_gallery_captions_are_english(anon):
flyers = anon.get("/flyers", headers={"Accept": "application/json"}).json()["items"]
captions = {item["caption"] for item in flyers}
assert "Thirty-one choices. You have zero." in captions
assert not any(" geen " in f" {caption.lower()} " or " een " in f" {caption.lower()} " for caption in captions)
def test_english_migration_helpers():
from molodetz.bootstrap import _translate_notification
assert _translate_notification("Ada wil meeschrijven") == "Ada wants to write along"
assert _translate_notification("Gepubliceerd: Geen cursus.") == "Published: No course."
assert _translate_notification("Back-up database klaar") == "Backup database finished"
assert _translate_notification("Je API-sleutel is vernieuwd.") == "Your API key was renewed."
assert _translate_notification("Already English") == "Already English"
+68
View File
@@ -0,0 +1,68 @@
# retoor <retoor@molodetz.nl>
import pytest
from molodetz.routers.legacy import LEGACY_PATHS
REDIRECTS = [
("/rol", "/roll"),
("/standaard", "/standard"),
("/mensen", "/people"),
("/mensen/retoor", "/people/retoor"),
("/binnen", "/join"),
("/voorwaarden", "/terms"),
("/docs/welkom", "/docs/welcome"),
("/docs/schrijven", "/docs/writing"),
("/docs/binnen", "/docs/join"),
("/docs/juridisch", "/docs/legal"),
("/docs/architectuur", "/docs/architecture"),
("/docs/api/inhoud", "/docs/api/content"),
("/docs/api/binnen", "/docs/api/join"),
]
@pytest.mark.parametrize("old,new", REDIRECTS)
def test_old_dutch_paths_redirect_permanently(anon, old, new):
response = anon.get(old)
assert response.status_code == 301, old
assert response.headers["location"] == new
assert anon.get(new).status_code == 200, new
def test_redirect_keeps_query_string(anon):
response = anon.get("/rol?before=2026-01-01")
assert response.status_code == 301
assert response.headers["location"] == "/roll?before=2026-01-01"
def test_old_join_post_keeps_method(anon):
response = anon.post("/binnen", data={"name": "x"})
assert response.status_code == 308
assert response.headers["location"] == "/join"
def test_legacy_paths_are_hidden_from_schema():
from molodetz.main import app
paths = app.openapi()["paths"]
assert not any(old in paths for old in LEGACY_PATHS)
assert "/mensen/{username}" not in paths
def test_old_post_slug_redirects_to_english_slug(anon, admin):
from molodetz.content import translate_seeded_posts
from molodetz.database import db, get_user_by_username, insert_post, update_post_row
author = get_user_by_username("retoor")
row = insert_post(author["uid"], "Af is een eigenschap.", "Een project dat open blijft, is een mening.", "rol", status="published", is_placeholder=True)
try:
assert translate_seeded_posts() >= 1
migrated = db["posts"].find_one(uid=row["uid"])
assert migrated["title"] == "Finished is a feature."
assert migrated["topic"] == "roll"
assert migrated["slug"].startswith("finished-is-a-feature-")
assert "Carried over from the old Molodetz page" in migrated["body"]
response = anon.get(f"/posts/{row['slug']}")
assert response.status_code == 301
assert response.headers["location"] == f"/posts/{migrated['slug']}"
finally:
update_post_row(row["uid"], deleted_at="2026-01-01T00:00:00+00:00", deleted_by="test")
+2 -2
View File
@@ -1,8 +1,8 @@
# retoor <retoor@molodetz.nl>
def test_mensen_lists_real_accounts_only(anon):
people = anon.get("/mensen", headers={"Accept": "application/json"}).json()
def test_people_lists_real_accounts_only(anon):
people = anon.get("/people", headers={"Accept": "application/json"}).json()
names = [person["username"] for person in people.get("people", [])]
assert names[0] == "retoor"
assert set(names) <= {"retoor", "lezer"}
+2 -2
View File
@@ -2,8 +2,8 @@
def test_post_page(anon):
posts = anon.get("/rol", headers={"Accept": "application/json"}).json()["posts"]
post = next(post for post in posts if post["title"] == "Geen cursus.")
posts = anon.get("/roll", headers={"Accept": "application/json"}).json()["posts"]
post = next(post for post in posts if post["title"] == "No course.")
response = anon.get(f"/posts/{post['slug']}")
assert response.status_code == 200
assert "Placeholder" in response.text
-11
View File
@@ -1,11 +0,0 @@
# retoor <retoor@molodetz.nl>
def test_seeded_posts_and_placeholders(anon):
rol = anon.get("/rol", headers={"Accept": "application/json"}).json()["posts"]
seeds = {"Een zes is geen ambitie.", "Af is een eigenschap.", "De assistent is geen senior.", "Geen cursus."}
assert seeds <= {post["title"] for post in rol}
assert all(post["is_placeholder"] for post in rol if post["title"] in seeds)
standaard = anon.get("/standaard", headers={"Accept": "application/json"}).json()["posts"]
keuzes = next(post for post in standaard if post["title"] == "Eenendertig keuzes. Jij hebt er nul.")
assert keuzes["is_placeholder"] is False
+33
View File
@@ -0,0 +1,33 @@
# retoor <retoor@molodetz.nl>
from molodetz.content import PLACEHOLDER_NOTE, SEED_POSTS
ROLL_SEEDS = {"A pass is not an ambition.", "Finished is a feature.", "The assistant is not a senior.", "No course."}
def test_seeded_posts_and_placeholders(anon):
roll = anon.get("/roll", headers={"Accept": "application/json"}).json()["posts"]
assert ROLL_SEEDS <= {post["title"] for post in roll}
assert all(post["is_placeholder"] for post in roll if post["title"] in ROLL_SEEDS)
assert all(post["topic"] == "roll" for post in roll)
standard = anon.get("/standard", headers={"Accept": "application/json"}).json()["posts"]
choices = next(post for post in standard if post["title"] == "Thirty-one choices. You have zero.")
assert choices["is_placeholder"] is False
assert choices["slug"].startswith("thirty-one-choices-you-have-zero-")
def test_seed_texts_are_english_and_keep_placeholder_marker():
assert {title for _, title, _ in SEED_POSTS} >= ROLL_SEEDS | {"Thirty-one choices. You have zero."}
assert PLACEHOLDER_NOTE.strip().startswith("> Placeholder.")
assert "retoor will replace it" in PLACEHOLDER_NOTE
for _, title, body in SEED_POSTS:
for dutch in (" een ", " het ", " niet ", " geen ", " wie "):
assert dutch not in f" {title.lower()} {body.lower()} ", (title, dutch)
assert chr(0x2014) not in body and chr(0x2013) not in body
def test_placeholder_note_renders_on_roll_post(anon):
roll = anon.get("/roll", headers={"Accept": "application/json"}).json()["posts"]
post = next(post for post in roll if post["title"] == "Finished is a feature.")
html = anon.get(f"/posts/{post['slug']}").text
assert "Carried over from the old Molodetz page" in html
assert "A project that stays open is an opinion." in html
+3 -3
View File
@@ -3,8 +3,8 @@ import re
import pytest
PUBLIC_PAGES = ["/", "/rol", "/standaard", "/flyers", "/memes", "/mensen", "/mensen/retoor", "/binnen", "/docs", "/docs/welkom", "/docs/api/inhoud", "/docs/search?q=markdown", "/voorwaarden", "/privacy", "/auth/login"]
JSON_PAGES = ["/", "/rol", "/standaard", "/flyers", "/memes", "/mensen", "/binnen"]
PUBLIC_PAGES = ["/", "/roll", "/standard", "/flyers", "/memes", "/people", "/people/retoor", "/join", "/docs", "/docs/welcome", "/docs/api/content", "/docs/search?q=markdown", "/terms", "/privacy", "/auth/login"]
JSON_PAGES = ["/", "/roll", "/standard", "/flyers", "/memes", "/people", "/join"]
@pytest.mark.parametrize("path", PUBLIC_PAGES)
@@ -23,7 +23,7 @@ def test_json_faces(anon, path):
def test_no_bare_static_urls_in_rendered_html(anon):
for path in ("/", "/flyers", "/memes", "/rol"):
for path in ("/", "/flyers", "/memes", "/roll"):
html = anon.get(path).text
assert not re.search(r"""(src|href)="/static/(?!v|uploads/)""", html), path
+1 -1
View File
@@ -1,7 +1,7 @@
# retoor <retoor@molodetz.nl>
from tests.conftest import login_admin
ADMIN_PAGES = ["/admin", "/admin/posts", "/admin/joins", "/admin/services", "/admin/services/backup", "/admin/trash", "/admin/settings", "/admin/users", "/admin/audit", "/admin/backups", "/admin/stats", "/notifications", "/profile/api-key", "/docs/api/beheer"]
ADMIN_PAGES = ["/admin", "/admin/posts", "/admin/joins", "/admin/services", "/admin/services/backup", "/admin/trash", "/admin/settings", "/admin/users", "/admin/audit", "/admin/backups", "/admin/stats", "/notifications", "/profile/api-key", "/docs/api/admin"]
def test_admin_pages_have_no_js_errors(page, app_server):
+1 -1
View File
@@ -2,7 +2,7 @@
def test_docs_tabs_switch(page, app_server):
page.goto(app_server + "/docs/api/inhoud", wait_until="domcontentloaded")
page.goto(app_server + "/docs/api/content", wait_until="domcontentloaded")
page.wait_for_selector("html[data-app=ready]")
page.click("[data-endpoint] [data-tab=expected] >> nth=0")
assert page.locator("[data-endpoint] [data-panel=expected]").first.is_visible()
+2 -2
View File
@@ -1,14 +1,14 @@
# retoor <retoor@molodetz.nl>
from tests.conftest import assert_no_horizontal_overflow
PUBLIC_PAGES = ["/", "/rol", "/standaard", "/flyers", "/memes", "/mensen", "/mensen/retoor", "/binnen", "/docs", "/docs/welkom", "/docs/api/inhoud", "/docs/component-code", "/voorwaarden", "/privacy"]
PUBLIC_PAGES = ["/", "/roll", "/standard", "/flyers", "/memes", "/people", "/people/retoor", "/join", "/docs", "/docs/welcome", "/docs/api/content", "/docs/component-code", "/terms", "/privacy"]
def test_home_renders_without_errors(page, app_server):
page.goto(app_server + "/", wait_until="domcontentloaded")
page.wait_for_selector("html[data-app=ready]")
assert page.locator("h1").first.inner_text().strip().lower() == "molodetz"
assert page.locator("nav .nav-links a", has_text="Binnen").count() == 1
assert page.locator("nav .nav-links a", has_text="Join").count() == 1
assert page.console_errors == []
+3 -3
View File
@@ -2,9 +2,9 @@
def test_join_form_submits(page, app_server):
page.goto(app_server + "/binnen", wait_until="domcontentloaded")
page.fill("input[name=name]", "Browser Schrijver")
page.goto(app_server + "/join", wait_until="domcontentloaded")
page.fill("input[name=name]", "Browser Writer")
page.fill("input[name=contact]", "browser@example.invalid")
page.click("form button[type=submit]")
page.wait_for_url("**/binnen?ok=1", wait_until="domcontentloaded")
page.wait_for_url("**/join?ok=1", wait_until="domcontentloaded")
assert page.console_errors == []
+2 -2
View File
@@ -4,8 +4,8 @@ from molodetz.cli.main import build_parser
def test_parser_groups():
parser = build_parser()
args = parser.parse_args(["posts", "import", "notitie.md", "--topic", "standaard"])
assert args.topic == "standaard" and args.path == "notitie.md"
args = parser.parse_args(["posts", "import", "note.md", "--topic", "standard"])
assert args.topic == "standard" and args.path == "note.md"
args = parser.parse_args(["system", "prune", "--dry-run"])
assert args.dry_run is True
args = parser.parse_args(["role", "set", "retoor", "Admin"])
+1 -1
View File
@@ -6,7 +6,7 @@ import pytest
from tests.conftest import TEST_DATA_DIR
QUERIES = [
("idx_posts_published", "SELECT * FROM posts WHERE topic = 'rol' AND status = 'published' AND deleted_at IS NULL ORDER BY published_at DESC LIMIT 25"),
("idx_posts_published", "SELECT * FROM posts WHERE topic = 'roll' AND status = 'published' AND deleted_at IS NULL ORDER BY published_at DESC LIMIT 25"),
("idx_posts_slug", "SELECT * FROM posts WHERE slug = 'x'"),
("idx_sessions_token", "SELECT * FROM sessions WHERE token = 'x'"),
("idx_users_api_key", "SELECT * FROM users WHERE api_key = 'x'"),
+1 -1
View File
@@ -8,7 +8,7 @@ from molodetz.utils.request_meta import safe_next
"value,expected",
[
("/admin", "/admin"),
("/rol?page=2", "/rol?page=2"),
("/roll?page=2", "/roll?page=2"),
("https://evil.example/", "/"),
("//evil.example/", "/"),
("/\\evil.example", "/"),