diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4278f27..d8da1e6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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/`, 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. diff --git a/molodetz/services/backup.py b/molodetz/services/backup.py index 703778d..8408e81 100644 --- a/molodetz/services/backup.py +++ b/molodetz/services/backup.py @@ -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] diff --git a/pyproject.toml b/pyproject.toml index 476732c..cec4504 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/api/admin/__init__.py b/tests/api/admin/__init__.py new file mode 100644 index 0000000..95ee7c3 --- /dev/null +++ b/tests/api/admin/__init__.py @@ -0,0 +1 @@ +# retoor diff --git a/tests/api/test_admin_posts.py b/tests/api/admin/posts.py similarity index 100% rename from tests/api/test_admin_posts.py rename to tests/api/admin/posts.py diff --git a/tests/api/auth/__init__.py b/tests/api/auth/__init__.py new file mode 100644 index 0000000..95ee7c3 --- /dev/null +++ b/tests/api/auth/__init__.py @@ -0,0 +1 @@ +# retoor diff --git a/tests/api/test_auth.py b/tests/api/auth/login.py similarity index 65% rename from tests/api/test_auth.py rename to tests/api/auth/login.py index c536f82..76e3f87 100644 --- a/tests/api/test_auth.py +++ b/tests/api/auth/login.py @@ -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 diff --git a/tests/api/test_binnen.py b/tests/api/binnen.py similarity index 100% rename from tests/api/test_binnen.py rename to tests/api/binnen.py diff --git a/tests/api/docs/__init__.py b/tests/api/docs/__init__.py new file mode 100644 index 0000000..95ee7c3 --- /dev/null +++ b/tests/api/docs/__init__.py @@ -0,0 +1 @@ +# retoor diff --git a/tests/api/test_auth_truth.py b/tests/api/docs/api.py similarity index 78% rename from tests/api/test_auth_truth.py rename to tests/api/docs/api.py index fc8172b..fd88f7e 100644 --- a/tests/api/test_auth_truth.py +++ b/tests/api/docs/api.py @@ -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) diff --git a/tests/api/flyers.py b/tests/api/flyers.py new file mode 100644 index 0000000..fd12e69 --- /dev/null +++ b/tests/api/flyers.py @@ -0,0 +1,7 @@ +# retoor + + +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 diff --git a/tests/api/health.py b/tests/api/health.py new file mode 100644 index 0000000..8a82474 --- /dev/null +++ b/tests/api/health.py @@ -0,0 +1,5 @@ +# retoor + + +def test_health(anon): + assert anon.get("/health").json()["status"] == "ok" diff --git a/tests/api/index.py b/tests/api/index.py new file mode 100644 index 0000000..c9280ad --- /dev/null +++ b/tests/api/index.py @@ -0,0 +1,15 @@ +# retoor + + +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}" 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"] diff --git a/tests/api/mensen.py b/tests/api/mensen.py new file mode 100644 index 0000000..8831200 --- /dev/null +++ b/tests/api/mensen.py @@ -0,0 +1,8 @@ +# retoor + + +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"} diff --git a/tests/api/posts.py b/tests/api/posts.py new file mode 100644 index 0000000..baa38af --- /dev/null +++ b/tests/api/posts.py @@ -0,0 +1,9 @@ +# retoor + + +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 diff --git a/tests/api/profile/__init__.py b/tests/api/profile/__init__.py new file mode 100644 index 0000000..95ee7c3 --- /dev/null +++ b/tests/api/profile/__init__.py @@ -0,0 +1 @@ +# retoor diff --git a/tests/api/profile/api_key.py b/tests/api/profile/api_key.py new file mode 100644 index 0000000..ca2c49c --- /dev/null +++ b/tests/api/profile/api_key.py @@ -0,0 +1,12 @@ +# retoor + + +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 diff --git a/tests/api/rol.py b/tests/api/rol.py new file mode 100644 index 0000000..56a80bc --- /dev/null +++ b/tests/api/rol.py @@ -0,0 +1,11 @@ +# retoor + + +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 diff --git a/tests/api/sitemap.py b/tests/api/sitemap.py new file mode 100644 index 0000000..39f6bb9 --- /dev/null +++ b/tests/api/sitemap.py @@ -0,0 +1,10 @@ +# retoor +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 diff --git a/tests/api/static.py b/tests/api/static.py new file mode 100644 index 0000000..bc988e2 --- /dev/null +++ b/tests/api/static.py @@ -0,0 +1,35 @@ +# retoor +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 diff --git a/tests/api/test_pages.py b/tests/api/test_pages.py deleted file mode 100644 index 64ab02f..0000000 --- a/tests/api/test_pages.py +++ /dev/null @@ -1,93 +0,0 @@ -# retoor -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}" 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" diff --git a/tests/conftest.py b/tests/conftest.py index 0099417..ca7e413 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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}" diff --git a/tests/e2e/admin/__init__.py b/tests/e2e/admin/__init__.py new file mode 100644 index 0000000..95ee7c3 --- /dev/null +++ b/tests/e2e/admin/__init__.py @@ -0,0 +1 @@ +# retoor diff --git a/tests/e2e/admin/index.py b/tests/e2e/admin/index.py new file mode 100644 index 0000000..d8cf619 --- /dev/null +++ b/tests/e2e/admin/index.py @@ -0,0 +1,16 @@ +# retoor +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 == [] diff --git a/tests/e2e/admin/posts.py b/tests/e2e/admin/posts.py new file mode 100644 index 0000000..04382a9 --- /dev/null +++ b/tests/e2e/admin/posts.py @@ -0,0 +1,16 @@ +# retoor +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 [link](javascript:alert(1)) b") + page.wait_for_function("document.querySelector('[data-preview-target] strong')") + preview = page.locator("[data-preview-target]").inner_html() + assert "vette" in preview + assert " + + +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 == [] diff --git a/tests/e2e/docs.py b/tests/e2e/docs.py new file mode 100644 index 0000000..c837928 --- /dev/null +++ b/tests/e2e/docs.py @@ -0,0 +1,17 @@ +# retoor + + +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 == [] diff --git a/tests/e2e/flyers.py b/tests/e2e/flyers.py new file mode 100644 index 0000000..94b8c8d --- /dev/null +++ b/tests/e2e/flyers.py @@ -0,0 +1,12 @@ +# retoor + + +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 == [] diff --git a/tests/e2e/index.py b/tests/e2e/index.py new file mode 100644 index 0000000..ac43940 --- /dev/null +++ b/tests/e2e/index.py @@ -0,0 +1,31 @@ +# retoor +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 == [] diff --git a/tests/e2e/test_browser.py b/tests/e2e/test_browser.py deleted file mode 100644 index b833c45..0000000 --- a/tests/e2e/test_browser.py +++ /dev/null @@ -1,102 +0,0 @@ -# retoor -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 [link](javascript:alert(1)) b") - page.wait_for_function("document.querySelector('[data-preview-target] strong')") - preview = page.locator("[data-preview-target]").inner_html() - assert "vette" in preview - assert "> 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 == [] diff --git a/tests/unit/cli/__init__.py b/tests/unit/cli/__init__.py new file mode 100644 index 0000000..95ee7c3 --- /dev/null +++ b/tests/unit/cli/__init__.py @@ -0,0 +1 @@ +# retoor diff --git a/tests/unit/test_cli.py b/tests/unit/cli/commands.py similarity index 55% rename from tests/unit/test_cli.py rename to tests/unit/cli/commands.py index 2ac10a8..4dd94e3 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/cli/commands.py @@ -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") diff --git a/tests/unit/cli/main.py b/tests/unit/cli/main.py new file mode 100644 index 0000000..97f0080 --- /dev/null +++ b/tests/unit/cli/main.py @@ -0,0 +1,12 @@ +# retoor +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" diff --git a/tests/unit/constants.py b/tests/unit/constants.py new file mode 100644 index 0000000..09eaa7c --- /dev/null +++ b/tests/unit/constants.py @@ -0,0 +1,8 @@ +# retoor +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) diff --git a/tests/unit/database/__init__.py b/tests/unit/database/__init__.py new file mode 100644 index 0000000..95ee7c3 --- /dev/null +++ b/tests/unit/database/__init__.py @@ -0,0 +1 @@ +# retoor diff --git a/tests/unit/test_query_plans.py b/tests/unit/database/init_db.py similarity index 100% rename from tests/unit/test_query_plans.py rename to tests/unit/database/init_db.py diff --git a/tests/unit/test_sql_lint.py b/tests/unit/database/sql_lint.py similarity index 100% rename from tests/unit/test_sql_lint.py rename to tests/unit/database/sql_lint.py diff --git a/tests/unit/docs_api/__init__.py b/tests/unit/docs_api/__init__.py new file mode 100644 index 0000000..95ee7c3 --- /dev/null +++ b/tests/unit/docs_api/__init__.py @@ -0,0 +1 @@ +# retoor diff --git a/tests/unit/test_registries.py b/tests/unit/docs_api/registry.py similarity index 59% rename from tests/unit/test_registries.py rename to tests/unit/docs_api/registry.py index eb37cd3..9d73dbe 100644 --- a/tests/unit/test_registries.py +++ b/tests/unit/docs_api/registry.py @@ -1,15 +1,8 @@ # retoor -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 diff --git a/tests/unit/test_gallery.py b/tests/unit/gallery.py similarity index 100% rename from tests/unit/test_gallery.py rename to tests/unit/gallery.py diff --git a/tests/unit/test_gates.py b/tests/unit/gates.py similarity index 100% rename from tests/unit/test_gates.py rename to tests/unit/gates.py diff --git a/tests/unit/main.py b/tests/unit/main.py new file mode 100644 index 0000000..ca60772 --- /dev/null +++ b/tests/unit/main.py @@ -0,0 +1,12 @@ +# retoor +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 diff --git a/tests/unit/test_net_guard.py b/tests/unit/net_guard.py similarity index 100% rename from tests/unit/test_net_guard.py rename to tests/unit/net_guard.py diff --git a/tests/unit/test_rendering.py b/tests/unit/rendering.py similarity index 100% rename from tests/unit/test_rendering.py rename to tests/unit/rendering.py diff --git a/tests/unit/utils/__init__.py b/tests/unit/utils/__init__.py new file mode 100644 index 0000000..95ee7c3 --- /dev/null +++ b/tests/unit/utils/__init__.py @@ -0,0 +1 @@ +# retoor diff --git a/tests/unit/test_request_meta.py b/tests/unit/utils/request_meta.py similarity index 100% rename from tests/unit/test_request_meta.py rename to tests/unit/utils/request_meta.py