Mirror test layout, domcontentloaded waits, backup DB writes outside threads
This commit is contained in:
+1
-1
@@ -5,7 +5,7 @@
|
||||
|
||||
- `molodetz/main.py`: FastAPI-object (`/swagger`, `/openapi.json`), lifespan (init-lock, `init_db`, bootstrap, services met service-lock), statische mounts (uploads, versioned `/static/v<STATIC_VERSION>/`, fallback), middleware in templatevolgorde, exception handlers, routers.
|
||||
- `molodetz/config.py`: enige plek voor `load_dotenv`, paden, env-bindingen met prefix `MOLODETZ_`, `APP_VERSION` via `tomllib`, `BOOT_ID`, `STATIC_VERSION`.
|
||||
- `molodetz/database/`: synchrone `dataset`-handle (SQLAlchemy NullPool, WAL-PRAGMA's), per-tabel modules, soft delete met registries, `init_db` met indexen en `ANALYZE`. Geen async driver; routes lopen in de threadpool waar nodig.
|
||||
- `molodetz/database/`: synchrone `dataset`-handle (SQLAlchemy NullPool, WAL-PRAGMA's), per-tabel modules, soft delete met registries, `init_db` met indexen en `ANALYZE`. Geen async driver en geen threadpool rond databasecalls: de synchrone laag wordt direct in async handlers aangeroepen (sectie 7.1). Alleen CPU- en bestandswerk (PBKDF2, archiefbouw, DNS) gaat via `asyncio.to_thread`.
|
||||
- `molodetz/routers/`: een module per oppervlak. Elke HTML-route geeft JSON bij `Accept: application/json` via `responses.respond` en een Pydantic-uitvoerschema uit `schemas/`.
|
||||
- `molodetz/templating.py`: de enige `Jinja2Templates`. Globals `static_url`, `local_dt`, `avatar_url`, `render_content` en meer.
|
||||
- `molodetz/rendering.py`: server-side markdown (mistune, escape aan), emoji, URL-allowlist, streepjesnormalisatie. Gepubliceerde inhoud wordt altijd server-side gerenderd.
|
||||
|
||||
@@ -131,18 +131,33 @@ def offload(path):
|
||||
return "ok" if result.returncode == 0 else f"failed: {result.stderr.strip()[:200]}"
|
||||
|
||||
|
||||
def process_backup(row, codec):
|
||||
try:
|
||||
directory, stored_name, size = build_archive(row["uid"], row["target"], codec)
|
||||
except Exception as exc:
|
||||
update_backup(row["uid"], status="failed", error=str(exc)[:500], finished_at=now_iso())
|
||||
record_system("backup.failed", actor_kind="service", origin="service", result="error", message=str(exc)[:200])
|
||||
def finish_backup(row, archive=None, error=None):
|
||||
if error is not None:
|
||||
update_backup(row["uid"], status="failed", error=str(error)[:500], finished_at=now_iso())
|
||||
record_system("backup.failed", actor_kind="service", origin="service", result="error", message=str(error)[:200])
|
||||
return False
|
||||
directory, stored_name, size = archive
|
||||
update_backup(row["uid"], status="done", directory=directory, stored_name=stored_name, size_bytes=size, finished_at=now_iso())
|
||||
record_system("backup.finished", actor_kind="service", origin="service", payload={"uid": row["uid"], "size": size})
|
||||
return True
|
||||
|
||||
|
||||
def process_backup(row, codec):
|
||||
try:
|
||||
archive = build_archive(row["uid"], row["target"], codec)
|
||||
except Exception as exc:
|
||||
return finish_backup(row, error=exc)
|
||||
return finish_backup(row, archive=archive)
|
||||
|
||||
|
||||
async def process_backup_async(row, codec):
|
||||
try:
|
||||
archive = await asyncio.to_thread(build_archive, row["uid"], row["target"], codec)
|
||||
except Exception as exc:
|
||||
return finish_backup(row, error=exc)
|
||||
return finish_backup(row, archive=archive)
|
||||
|
||||
|
||||
class BackupService(BaseService):
|
||||
name = "backup"
|
||||
title = "Back-ups"
|
||||
@@ -178,7 +193,7 @@ class BackupService(BaseService):
|
||||
self.log(f"automatische back-up ingepland: {cfg['schedule_target']}")
|
||||
for row in pending_backups():
|
||||
update_backup(row["uid"], status="running")
|
||||
ok = await asyncio.to_thread(process_backup, row, cfg["codec"])
|
||||
ok = await process_backup_async(row, cfg["codec"])
|
||||
self.processed += 1
|
||||
self.log(f"back-up {row['target']} {'klaar' if ok else 'mislukt'}")
|
||||
primary = admin_uids_ordered()[:1]
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "molodetz"
|
||||
version = "1.0.3"
|
||||
version = "1.0.4"
|
||||
description = "Molodetz, a calm community blog roll."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -22,14 +22,3 @@ def test_admin_redirects_anonymous(anon):
|
||||
response = anon.get("/admin")
|
||||
assert response.status_code in (302, 303)
|
||||
assert "/auth/login" in response.headers["location"]
|
||||
|
||||
|
||||
def test_api_key_header(admin, anon):
|
||||
key = admin.get("/profile/api-key", headers={"Accept": "application/json"}).json()["api_key"]
|
||||
assert anon.get("/admin", headers={"X-API-KEY": key, "Accept": "application/json"}).status_code == 200
|
||||
assert anon.get("/admin", headers={"Authorization": f"Bearer {key}", "Accept": "application/json"}).status_code == 200
|
||||
|
||||
|
||||
def test_no_jwt_anywhere(admin):
|
||||
response = admin.get("/profile/api-key", headers={"Accept": "application/json"})
|
||||
assert response.json()["api_key"].count(".") == 0
|
||||
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -17,7 +17,10 @@ def _call(client, item):
|
||||
@pytest.mark.parametrize("item", ENDPOINTS, ids=lambda item: f"{item['method']} {item['path']}")
|
||||
def test_documented_auth_matches_reality(item, anon, member, admin):
|
||||
anon_status = _call(anon, item).status_code
|
||||
member_status = _call(member, item).status_code
|
||||
member_response = _call(member, item)
|
||||
member_status = member_response.status_code
|
||||
if member_status == 403 and member_response.headers.get("content-type", "").startswith("application/json"):
|
||||
assert member_response.json().get("error", {}).get("code") != "terms_required"
|
||||
admin_status = _call(admin, item).status_code
|
||||
if item["auth"] == "public":
|
||||
assert anon_status not in (401, 403)
|
||||
@@ -0,0 +1,7 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
|
||||
def test_galleries_have_images(anon):
|
||||
for path, minimum in (("/flyers", 4), ("/memes", 10)):
|
||||
html = anon.get(path).text
|
||||
assert html.count("data-lightbox") >= minimum, path
|
||||
@@ -0,0 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
|
||||
def test_health(anon):
|
||||
assert anon.get("/health").json()["status"] == "ok"
|
||||
@@ -0,0 +1,15 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
|
||||
def test_home_is_molodetz(anon):
|
||||
html = anon.get("/").text
|
||||
assert "Molodetz" in html
|
||||
for label in ("Rol", "Standaard", "Flyers", "Memes", "Mensen", "Binnen"):
|
||||
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")
|
||||
assert response.status_code == 404
|
||||
assert anon.get("/bestaat-niet-xyz", headers={"Accept": "application/json"}).json()["error"]
|
||||
@@ -0,0 +1,8 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
|
||||
def test_mensen_lists_real_accounts_only(anon):
|
||||
people = anon.get("/mensen", headers={"Accept": "application/json"}).json()
|
||||
names = [person["username"] for person in people.get("people", [])]
|
||||
assert names[0] == "retoor"
|
||||
assert set(names) <= {"retoor", "lezer"}
|
||||
@@ -0,0 +1,9 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
|
||||
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.")
|
||||
response = anon.get(f"/posts/{post['slug']}")
|
||||
assert response.status_code == 200
|
||||
assert "Placeholder" in response.text
|
||||
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -0,0 +1,12 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
|
||||
def test_api_key_header(admin, anon):
|
||||
key = admin.get("/profile/api-key", headers={"Accept": "application/json"}).json()["api_key"]
|
||||
assert anon.get("/admin", headers={"X-API-KEY": key, "Accept": "application/json"}).status_code == 200
|
||||
assert anon.get("/admin", headers={"Authorization": f"Bearer {key}", "Accept": "application/json"}).status_code == 200
|
||||
|
||||
|
||||
def test_no_jwt_anywhere(admin):
|
||||
response = admin.get("/profile/api-key", headers={"Accept": "application/json"})
|
||||
assert response.json()["api_key"].count(".") == 0
|
||||
@@ -0,0 +1,11 @@
|
||||
# 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
|
||||
@@ -0,0 +1,10 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from defusedxml import ElementTree
|
||||
|
||||
|
||||
def test_robots_and_sitemap(anon):
|
||||
assert "Sitemap:" in anon.get("/robots.txt").text
|
||||
sitemap = anon.get("/sitemap.xml")
|
||||
root = ElementTree.fromstring(sitemap.content)
|
||||
assert root.tag.endswith("urlset")
|
||||
assert len(list(root)) >= 5
|
||||
@@ -0,0 +1,35 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
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"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", PUBLIC_PAGES)
|
||||
def test_public_pages_return_200(anon, path):
|
||||
response = anon.get(path)
|
||||
assert response.status_code == 200, path
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", JSON_PAGES)
|
||||
def test_json_faces(anon, path):
|
||||
response = anon.get(path, headers={"Accept": "application/json"})
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("application/json")
|
||||
assert isinstance(response.json(), dict)
|
||||
|
||||
|
||||
def test_no_bare_static_urls_in_rendered_html(anon):
|
||||
for path in ("/", "/flyers", "/memes", "/rol"):
|
||||
html = anon.get(path).text
|
||||
assert not re.search(r"""(src|href)="/static/(?!v|uploads/)""", html), path
|
||||
|
||||
|
||||
def test_versioned_static_asset(anon):
|
||||
html = anon.get("/").text
|
||||
match = re.search(r'href="(/static/v[^"]+/css/base\.css)"', html)
|
||||
assert match
|
||||
assert anon.get(match.group(1)).status_code == 200
|
||||
@@ -1,93 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from defusedxml import ElementTree
|
||||
|
||||
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"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", PUBLIC_PAGES)
|
||||
def test_public_pages_return_200(anon, path):
|
||||
response = anon.get(path)
|
||||
assert response.status_code == 200, path
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", JSON_PAGES)
|
||||
def test_json_faces(anon, path):
|
||||
response = anon.get(path, headers={"Accept": "application/json"})
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("application/json")
|
||||
assert isinstance(response.json(), dict)
|
||||
|
||||
|
||||
def test_home_is_molodetz(anon):
|
||||
html = anon.get("/").text
|
||||
assert "Molodetz" in html
|
||||
for label in ("Rol", "Standaard", "Flyers", "Memes", "Mensen", "Binnen"):
|
||||
assert f">{label}</a>" in html
|
||||
assert "wa.me" not in html and "whatsapp" not in html.lower()
|
||||
|
||||
|
||||
def test_no_bare_static_urls_in_rendered_html(anon):
|
||||
for path in ("/", "/flyers", "/memes", "/rol"):
|
||||
html = anon.get(path).text
|
||||
assert not re.search(r"""(src|href)="/static/(?!v|uploads/)""", html), path
|
||||
|
||||
|
||||
def test_versioned_static_asset(anon):
|
||||
html = anon.get("/").text
|
||||
match = re.search(r'href="(/static/v[^"]+/css/base\.css)"', html)
|
||||
assert match
|
||||
assert anon.get(match.group(1)).status_code == 200
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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.")
|
||||
response = anon.get(f"/posts/{post['slug']}")
|
||||
assert response.status_code == 200
|
||||
assert "Placeholder" in response.text
|
||||
|
||||
|
||||
def test_galleries_have_images(anon):
|
||||
for path, minimum in (("/flyers", 4), ("/memes", 10)):
|
||||
html = anon.get(path).text
|
||||
assert html.count("data-lightbox") >= minimum, path
|
||||
|
||||
|
||||
def test_mensen_lists_real_accounts_only(anon):
|
||||
people = anon.get("/mensen", headers={"Accept": "application/json"}).json()
|
||||
names = [person["username"] for person in people.get("people", [])]
|
||||
assert names[0] == "retoor"
|
||||
assert set(names) <= {"retoor", "lezer"}
|
||||
|
||||
|
||||
def test_unknown_page_is_404(anon):
|
||||
response = anon.get("/bestaat-niet-xyz")
|
||||
assert response.status_code == 404
|
||||
assert anon.get("/bestaat-niet-xyz", headers={"Accept": "application/json"}).json()["error"]
|
||||
|
||||
|
||||
def test_robots_and_sitemap(anon):
|
||||
assert "Sitemap:" in anon.get("/robots.txt").text
|
||||
sitemap = anon.get("/sitemap.xml")
|
||||
root = ElementTree.fromstring(sitemap.content)
|
||||
assert root.tag.endswith("urlset")
|
||||
assert len(list(root)) >= 5
|
||||
|
||||
|
||||
def test_health(anon):
|
||||
assert anon.get("/health").json()["status"] == "ok"
|
||||
@@ -141,3 +141,16 @@ def page(browser):
|
||||
|
||||
def pytest_sessionfinish(session, exitstatus):
|
||||
shutil.rmtree(TEST_DATA_DIR, ignore_errors=True)
|
||||
|
||||
|
||||
def login_admin(page, base_url):
|
||||
page.goto(base_url + "/auth/login", wait_until="domcontentloaded")
|
||||
page.fill("input[name=username]", ADMIN_USERNAME)
|
||||
page.fill("input[name=password]", ADMIN_PASSWORD)
|
||||
page.click("button[type=submit]")
|
||||
page.wait_for_url("**/admin", wait_until="domcontentloaded")
|
||||
|
||||
|
||||
def assert_no_horizontal_overflow(page):
|
||||
width, viewport = page.evaluate("[document.documentElement.scrollWidth, window.innerWidth]")
|
||||
assert width <= viewport, f"horizontal overflow: {width} > {viewport}"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -0,0 +1,16 @@
|
||||
# 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"]
|
||||
|
||||
|
||||
def test_admin_pages_have_no_js_errors(page, app_server):
|
||||
login_admin(page, app_server)
|
||||
for path in ADMIN_PAGES:
|
||||
page.goto(app_server + path, wait_until="domcontentloaded")
|
||||
page.wait_for_selector("html[data-app=ready]")
|
||||
page.goto(app_server + "/admin/services/backup", wait_until="domcontentloaded")
|
||||
page.click("[data-tab=logs]")
|
||||
assert page.locator("[data-panel=logs]").is_visible()
|
||||
page.wait_for_timeout(3500)
|
||||
assert page.console_errors == []
|
||||
@@ -0,0 +1,16 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from tests.conftest import login_admin
|
||||
|
||||
|
||||
def test_login_and_editor_preview(page, app_server):
|
||||
login_admin(page, app_server)
|
||||
page.goto(app_server + "/admin/posts/new", wait_until="domcontentloaded")
|
||||
page.wait_for_selector("html[data-app=ready]")
|
||||
page.fill("#body", "Een **vette** zin <script>alert(1)</script> [link](javascript:alert(1)) <b onclick=alert(1)>b</b>")
|
||||
page.wait_for_function("document.querySelector('[data-preview-target] strong')")
|
||||
preview = page.locator("[data-preview-target]").inner_html()
|
||||
assert "<strong>vette</strong>" in preview
|
||||
assert "<script" not in preview
|
||||
assert "javascript:" not in preview
|
||||
assert "onclick" not in preview
|
||||
assert page.console_errors == []
|
||||
@@ -0,0 +1,10 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
|
||||
def test_join_form_submits(page, app_server):
|
||||
page.goto(app_server + "/binnen", wait_until="domcontentloaded")
|
||||
page.fill("input[name=name]", "Browser Schrijver")
|
||||
page.fill("input[name=contact]", "browser@example.invalid")
|
||||
page.click("form button[type=submit]")
|
||||
page.wait_for_url("**/binnen?ok=1", wait_until="domcontentloaded")
|
||||
assert page.console_errors == []
|
||||
@@ -0,0 +1,17 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
|
||||
def test_docs_tabs_switch(page, app_server):
|
||||
page.goto(app_server + "/docs/api/inhoud", 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()
|
||||
assert page.console_errors == []
|
||||
|
||||
|
||||
def test_docs_code_blocks_get_highlight_and_copy(page, app_server):
|
||||
page.goto(app_server + "/docs/api", wait_until="domcontentloaded")
|
||||
page.wait_for_selector("html[data-app=ready]")
|
||||
page.wait_for_selector("dp-code .copy-button")
|
||||
assert page.locator("dp-code pre code.hljs").count() >= 1
|
||||
assert page.console_errors == []
|
||||
@@ -0,0 +1,12 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
|
||||
def test_lightbox_opens_and_closes(page, app_server):
|
||||
page.goto(app_server + "/flyers", wait_until="domcontentloaded")
|
||||
page.wait_for_selector("html[data-app=ready]")
|
||||
page.locator("[data-lightbox]").first.click()
|
||||
lightbox = page.locator("dp-lightbox")
|
||||
assert lightbox.evaluate("el => el.classList.contains('visible')")
|
||||
page.keyboard.press("Escape")
|
||||
assert not lightbox.evaluate("el => el.classList.contains('visible')")
|
||||
assert page.console_errors == []
|
||||
@@ -0,0 +1,31 @@
|
||||
# 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"]
|
||||
|
||||
|
||||
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.console_errors == []
|
||||
|
||||
|
||||
def test_mobile_nav_toggle(browser, app_server):
|
||||
context = browser.new_context(viewport={"width": 390, "height": 800})
|
||||
page = context.new_page()
|
||||
page.goto(app_server + "/", wait_until="domcontentloaded")
|
||||
page.wait_for_selector("html[data-app=ready]")
|
||||
assert_no_horizontal_overflow(page)
|
||||
assert not page.locator("#nav-links").is_visible()
|
||||
page.click("[data-nav-toggle]")
|
||||
assert page.locator("#nav-links").is_visible()
|
||||
context.close()
|
||||
|
||||
|
||||
def test_public_pages_have_no_js_errors(page, app_server):
|
||||
for path in PUBLIC_PAGES:
|
||||
page.goto(app_server + path, wait_until="domcontentloaded")
|
||||
page.wait_for_selector("html[data-app=ready]")
|
||||
assert page.console_errors == []
|
||||
@@ -1,102 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from tests.conftest import ADMIN_PASSWORD, ADMIN_USERNAME
|
||||
|
||||
|
||||
def test_home_renders_without_errors(page, app_server):
|
||||
page.goto(app_server + "/")
|
||||
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.console_errors == []
|
||||
|
||||
|
||||
def test_lightbox_opens_and_closes(page, app_server):
|
||||
page.goto(app_server + "/flyers")
|
||||
page.wait_for_selector("html[data-app=ready]")
|
||||
page.locator("[data-lightbox]").first.click()
|
||||
lightbox = page.locator("dp-lightbox")
|
||||
assert lightbox.evaluate("el => el.classList.contains('visible')")
|
||||
page.keyboard.press("Escape")
|
||||
assert not lightbox.evaluate("el => el.classList.contains('visible')")
|
||||
assert page.console_errors == []
|
||||
|
||||
|
||||
def test_mobile_nav_toggle(browser, app_server):
|
||||
context = browser.new_context(viewport={"width": 390, "height": 800})
|
||||
page = context.new_page()
|
||||
page.goto(app_server + "/")
|
||||
page.wait_for_selector("html[data-app=ready]")
|
||||
assert not page.locator("#nav-links").is_visible()
|
||||
page.click("[data-nav-toggle]")
|
||||
assert page.locator("#nav-links").is_visible()
|
||||
context.close()
|
||||
|
||||
|
||||
def test_login_and_editor_preview(page, app_server):
|
||||
page.goto(app_server + "/auth/login")
|
||||
page.fill("input[name=username]", ADMIN_USERNAME)
|
||||
page.fill("input[name=password]", ADMIN_PASSWORD)
|
||||
page.click("button[type=submit]")
|
||||
page.wait_for_url("**/admin")
|
||||
page.goto(app_server + "/admin/posts/new")
|
||||
page.wait_for_selector("html[data-app=ready]")
|
||||
page.fill("#body", "Een **vette** zin <script>alert(1)</script> [link](javascript:alert(1)) <b onclick=alert(1)>b</b>")
|
||||
page.wait_for_function("document.querySelector('[data-preview-target] strong')")
|
||||
preview = page.locator("[data-preview-target]").inner_html()
|
||||
assert "<strong>vette</strong>" in preview
|
||||
assert "<script" not in preview
|
||||
assert "javascript:" not in preview
|
||||
assert "onclick" not in preview
|
||||
assert page.console_errors == []
|
||||
|
||||
|
||||
def test_join_form_submits(page, app_server):
|
||||
page.goto(app_server + "/binnen")
|
||||
page.fill("input[name=name]", "Browser Schrijver")
|
||||
page.fill("input[name=contact]", "browser@example.invalid")
|
||||
page.click("form button[type=submit]")
|
||||
page.wait_for_url("**/binnen?ok=1")
|
||||
assert page.console_errors == []
|
||||
|
||||
|
||||
PUBLIC_PAGES = ["/", "/rol", "/standaard", "/flyers", "/memes", "/mensen", "/mensen/retoor", "/binnen", "/docs", "/docs/welkom", "/docs/api/inhoud", "/docs/component-code", "/voorwaarden", "/privacy"]
|
||||
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"]
|
||||
|
||||
|
||||
def test_public_pages_have_no_js_errors(page, app_server):
|
||||
for path in PUBLIC_PAGES:
|
||||
page.goto(app_server + path)
|
||||
page.wait_for_selector("html[data-app=ready]")
|
||||
assert page.console_errors == []
|
||||
|
||||
|
||||
def test_admin_pages_have_no_js_errors(page, app_server):
|
||||
page.goto(app_server + "/auth/login")
|
||||
page.fill("input[name=username]", ADMIN_USERNAME)
|
||||
page.fill("input[name=password]", ADMIN_PASSWORD)
|
||||
page.click("button[type=submit]")
|
||||
page.wait_for_url("**/admin")
|
||||
for path in ADMIN_PAGES:
|
||||
page.goto(app_server + path)
|
||||
page.wait_for_selector("html[data-app=ready]")
|
||||
page.goto(app_server + "/admin/services/backup")
|
||||
page.click("[data-tab=logs]")
|
||||
assert page.locator("[data-panel=logs]").is_visible()
|
||||
page.wait_for_timeout(3500)
|
||||
assert page.console_errors == []
|
||||
|
||||
|
||||
def test_docs_tabs_switch(page, app_server):
|
||||
page.goto(app_server + "/docs/api/inhoud")
|
||||
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()
|
||||
assert page.console_errors == []
|
||||
|
||||
|
||||
def test_docs_code_blocks_get_highlight_and_copy(page, app_server):
|
||||
page.goto(app_server + "/docs/api")
|
||||
page.wait_for_selector("html[data-app=ready]")
|
||||
page.wait_for_selector("dp-code .copy-button")
|
||||
assert page.locator("dp-code pre code.hljs").count() >= 1
|
||||
assert page.console_errors == []
|
||||
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -3,20 +3,9 @@ import io
|
||||
|
||||
from pypdf import PdfWriter
|
||||
|
||||
from molodetz.cli import build_parser
|
||||
from molodetz.cli.commands import read_source
|
||||
|
||||
|
||||
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(["system", "prune", "--dry-run"])
|
||||
assert args.dry_run is True
|
||||
args = parser.parse_args(["role", "set", "retoor", "Admin"])
|
||||
assert args.role == "Admin"
|
||||
|
||||
|
||||
def test_read_markdown(tmp_path):
|
||||
path = tmp_path / "x.md"
|
||||
path.write_text("# Kop\n\nTekst", encoding="utf-8")
|
||||
@@ -0,0 +1,12 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
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(["system", "prune", "--dry-run"])
|
||||
assert args.dry_run is True
|
||||
args = parser.parse_args(["role", "set", "retoor", "Admin"])
|
||||
assert args.role == "Admin"
|
||||
@@ -0,0 +1,8 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from molodetz.constants import REPORTABLE_TARGETS, SOFT_DELETE_TABLES, UNREPORTABLE_TABLES
|
||||
|
||||
|
||||
def test_soft_delete_tables_partition():
|
||||
tables = set(SOFT_DELETE_TABLES)
|
||||
assert tables == set(REPORTABLE_TARGETS) | set(UNREPORTABLE_TABLES)
|
||||
assert not set(REPORTABLE_TARGETS) & set(UNREPORTABLE_TABLES)
|
||||
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -1,15 +1,8 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from molodetz.constants import REPORTABLE_TARGETS, SOFT_DELETE_TABLES, UNREPORTABLE_TABLES
|
||||
from molodetz.docs_api import all_endpoints
|
||||
from molodetz.docs_api.registry import TARGET_TYPES
|
||||
|
||||
|
||||
def test_soft_delete_tables_partition():
|
||||
tables = set(SOFT_DELETE_TABLES)
|
||||
assert tables == set(REPORTABLE_TARGETS) | set(UNREPORTABLE_TABLES)
|
||||
assert not set(REPORTABLE_TARGETS) & set(UNREPORTABLE_TABLES)
|
||||
|
||||
|
||||
def test_documented_endpoints_are_unique():
|
||||
keys = [(item["method"], item["path"]) for item in all_endpoints()]
|
||||
assert len(keys) == len(set(keys))
|
||||
@@ -25,10 +18,3 @@ def test_documented_endpoints_exist_in_app():
|
||||
|
||||
def test_target_types_known():
|
||||
assert set(TARGET_TYPES) == {"post", "join_request", "user"}
|
||||
|
||||
|
||||
def test_no_oauth_redirect_route():
|
||||
from molodetz.main import app
|
||||
|
||||
assert "/docs/oauth2-redirect" not in app.openapi()["paths"]
|
||||
assert app.swagger_ui_oauth2_redirect_url is None
|
||||
@@ -0,0 +1,12 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from molodetz.main import app
|
||||
|
||||
|
||||
def test_no_oauth_redirect_route():
|
||||
assert "/docs/oauth2-redirect" not in app.openapi()["paths"]
|
||||
assert app.swagger_ui_oauth2_redirect_url is None
|
||||
|
||||
|
||||
def test_framework_docs_moved():
|
||||
assert app.docs_url == "/swagger"
|
||||
assert app.redoc_url is None
|
||||
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
Reference in New Issue
Block a user