feat: professionalize platform release 0.3.13
deploy / deploy (push) Canceled after 0s

This commit is contained in:
Jens
2026-07-29 16:35:37 +02:00
parent 78fcf4fa5f
commit 5e2453c29a
40 changed files with 582 additions and 65 deletions
@@ -9,7 +9,7 @@ from bs4 import BeautifulSoup
from django.urls import reverse
from django.utils import timezone
from apps.jobs.models import Application, Feedback, ScoreRun
from apps.jobs.models import ScoreRun
try:
from playwright.sync_api import sync_playwright
@@ -85,7 +85,7 @@ def _run_html_accessibility_probe(client, user, profile, job) -> None:
response = client.get(reverse("dashboard:today"))
assert response.status_code == 200
_assert_basic_html_accessibility(response.content, page_name="dashboard")
assert "Zoek door vacatures" in response.content.decode("utf-8")
assert "Zoek vacatures" in response.content.decode("utf-8")
response = client.get(reverse("jobs:list"), {"q": job.original_title})
assert response.status_code == 200
@@ -133,17 +133,28 @@ def _run_html_accessibility_probe(client, user, profile, job) -> None:
def _assert_playwright_accessibility(page, *, expected_h1: str) -> None:
page.wait_for_load_state("networkidle")
assert page.get_by_role("heading", level=1).count() == 1
assert expected_h1 in page.get_by_role("heading", level=1).all_text_contents()
headings = page.get_by_role("heading", level=1).all_text_contents()
assert any(expected_h1 in heading for heading in headings)
page.keyboard.press("Tab")
focused_tag = page.evaluate("document.activeElement.tagName.toLowerCase()")
assert focused_tag != "body"
outline_style = page.evaluate("getComputedStyle(document.activeElement).outlineStyle")
assert outline_style != "none"
focus_indicator = page.evaluate(
"""() => {
const style = getComputedStyle(document.activeElement);
return {outline: style.outlineStyle, shadow: style.boxShadow};
}"""
)
assert focus_indicator["outline"] != "none" or focus_indicator["shadow"] != "none"
viewport_width = page.evaluate("window.innerWidth")
document_width = page.evaluate("document.documentElement.scrollWidth")
assert document_width <= viewport_width + 1
def _take_failure_screenshot(page, tmp_path: Path, viewport_name: str) -> None:
directory = tmp_path / "artifacts"
del tmp_path
directory = Path(__file__).resolve().parents[2] / "artifacts" / "browser-tests"
directory.mkdir(parents=True, exist_ok=True)
page.screenshot(path=str(directory / f"vr114_e2e_failure_{viewport_name}.png"), full_page=True)
@@ -159,14 +170,19 @@ def test_vr114_html_a11y_probe(client, user, profile, job):
@pytest.mark.parametrize(
"viewport",
[
{"name": "desktop", "width": 1366, "height": 900},
{"name": "mobile-360", "width": 360, "height": 780},
{"name": "mobile-390", "width": 390, "height": 844},
{"name": "tablet-768", "width": 768, "height": 1024},
{"name": "laptop-1280", "width": 1280, "height": 800},
{"name": "desktop-1920", "width": 1920, "height": 1080},
{"name": "wide-2560", "width": 2560, "height": 1440},
{"name": "ultrawide-3440", "width": 3440, "height": 1200},
],
)
def test_vr114_browser_flow(viewport, live_server, client, tmp_path, user, profile, job):
_seed_dashboard_score(profile=profile, job=job)
if sync_playwright is None:
pytest.fail("Playwright is verplicht voor de E2E- en accessibilitygate.")
_run_html_accessibility_probe(client, user, profile, job)
pytest.skip("Playwright niet geïnstalleerd. Vult alleen de HTML/a11y-probe in.")
@@ -183,6 +199,7 @@ def test_vr114_browser_flow(viewport, live_server, client, tmp_path, user, profi
try:
browser = playwright.chromium.launch(headless=True)
except Exception as exc: # pragma: no cover - env-dependent
pytest.fail(f"Playwright Chromium is verplicht maar niet beschikbaar: {exc}")
# De afzonderlijke HTML/a11y-probe is altijd uitgevoerd. Buiten de
# Playwright-context opnieuw databasewerk doen kan Django als async zien.
pytest.skip(f"Playwright-browser niet beschikbaar: {exc}")
@@ -192,52 +209,58 @@ def test_vr114_browser_flow(viewport, live_server, client, tmp_path, user, profi
reduced_motion="reduce",
)
page = context.new_page()
console_errors: list[str] = []
page_errors: list[str] = []
page.on(
"console",
lambda message: console_errors.append(message.text)
if message.type == "error"
else None,
)
page.on("pageerror", lambda error: page_errors.append(str(error)))
try:
# Login (e-mailadres + wachtwoord, Stitch-ontwerp).
page.goto(login_url)
_assert_playwright_accessibility(page, expected_h1="Inloggen")
page.get_by_label("E-mailadres").fill(user.email)
page.get_by_label("Wachtwoord").fill("correct-horse-battery-staple")
page.get_by_role("textbox", name="Wachtwoord", exact=True).fill(
"correct-horse-battery-staple"
)
page.get_by_role("button", name="Inloggen").click()
page.wait_for_url(dashboard_url)
_assert_playwright_accessibility(page, expected_h1="Goedemorgen")
# Dashboard -> filter -> detail.
page.get_by_role("link", name="Vacatures").click()
page.get_by_role("textbox", name="Zoeken").fill(job.original_title)
page.get_by_role("button", name="Filters toepassen").click()
page.goto(jobs_url)
page.get_by_label("Zoeken", exact=True).fill(job.original_title)
page.get_by_role("button", name="Filter", exact=True).click()
assert page.url.startswith(jobs_url)
page.get_by_role("link", name=job.original_title).first.click()
page.get_by_role("link", name="Volledige analyse").click()
page.wait_for_url(detail_url)
_assert_playwright_accessibility(page, expected_h1=job.original_title)
# Vier feedbackacties.
page.get_by_role("button", name="Interessant").click()
page.wait_for_selector(".message.success")
page.wait_for_selector(".toast.success")
page.get_by_role("button", name="Bewaren").click()
page.wait_for_selector(".message.success")
page.get_by_label("Reden (optioneel)").select_option("Te ver")
page.wait_for_selector(".toast.success")
page.get_by_role("button", name="Verbergen").click()
page.wait_for_selector(".message.success")
page.get_by_role("button", name="Gesolliciteerd").click()
page.wait_for_selector(".message.success")
page.wait_for_selector(".toast.success")
page.get_by_role("button", name="Dossier starten").click()
page.wait_for_selector(".toast.success")
# Profiel aanpassen.
page.get_by_role("link", name="Zoekprofiel").click()
page.wait_for_url(profile_list_url)
page.get_by_role("link", name="Profiel bewerken").click()
page.goto(profile_list_url)
page.get_by_role("link", name="Radar configureren").click()
page.get_by_label("Infrastructure engineer").check()
page.get_by_label("System engineer").check()
page.get_by_role("button", name="Instellingen opslaan").click()
page.wait_for_url(profile_list_url)
# Sollicitatie dossier openen en bijwerken.
application = Application.objects.filter(user=user, job=job).first()
assert application is not None
page.get_by_role("link", name="Sollicitaties").click()
page.wait_for_url(applications_url)
page.goto(f"{base}{reverse('jobs:application-edit', kwargs={'pk': application.pk})}")
page.goto(applications_url)
page.get_by_role("link", name="Open dossier").click()
page.locator("select[name='status']").select_option("interview")
page.locator("input[name='contact_name']").fill("Sofie Janssens")
page.locator("input[name='contact_email']").fill("sofia@example.invalid")
@@ -249,8 +272,7 @@ def test_vr114_browser_flow(viewport, live_server, client, tmp_path, user, profi
page.wait_for_url(applications_url)
# Handmatige import via plaktekst.
page.get_by_role("link", name="Bronnen").click()
page.wait_for_url(sources_url)
page.goto(sources_url)
page.locator(".import-disclosure > summary").click()
page.locator("textarea[name='pasted_text']").fill(
"Vacaturetitel: Infrastructure Engineer\nWerkgever: Example IT\nLocatie: Hasselt"
@@ -264,9 +286,10 @@ def test_vr114_browser_flow(viewport, live_server, client, tmp_path, user, profi
page.keyboard.press("Tab")
page.keyboard.press("Tab")
page.keyboard.press("Tab")
_assert_playwright_accessibility(page, expected_h1="Bronnen")
_assert_playwright_accessibility(page, expected_h1="Scan-endpoints")
assert console_errors == []
assert page_errors == []
assert Feedback.objects.filter(user=user, job=job).count() >= 4
except Exception:
_take_failure_screenshot(page, tmp_path, viewport["name"])
raise
+14
View File
@@ -11,6 +11,7 @@ from django.urls import reverse
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
def test_login_page_renders_stitch_design(client):
response = client.get(reverse("login"))
assert response.status_code == 200
@@ -41,6 +42,7 @@ def test_login_page_keeps_official_brand_separate_from_owner_setting(client):
assert "Eigen merk" not in body
assert 'content="VacatureRadar"' in body
def test_login_with_email(client, user):
response = client.post(
reverse("login"),
@@ -50,6 +52,7 @@ def test_login_with_email(client, user):
assert response.url == reverse("dashboard:today")
assert client.session.get("_auth_user_id") == str(user.pk)
def test_login_with_username_still_works(client, user):
response = client.post(
reverse("login"),
@@ -58,6 +61,7 @@ def test_login_with_username_still_works(client, user):
assert response.status_code == 302
assert client.session.get("_auth_user_id") == str(user.pk)
def test_login_email_case_insensitive(client, user):
response = client.post(
reverse("login"),
@@ -66,6 +70,7 @@ def test_login_email_case_insensitive(client, user):
assert response.status_code == 302
assert client.session.get("_auth_user_id") == str(user.pk)
def test_login_wrong_password_shows_error(client, user):
response = client.post(
reverse("login"),
@@ -74,6 +79,7 @@ def test_login_wrong_password_shows_error(client, user):
assert response.status_code == 200
assert "klopt niet" in response.content.decode("utf-8")
def test_password_reset_sends_email(client, user):
response = client.post(reverse("password_reset"), {"email": user.email})
assert response.status_code == 302
@@ -84,11 +90,13 @@ def test_password_reset_sends_email(client, user):
assert "wachtwoord" in message.subject.lower()
assert "/wachtwoord/herstellen/" in message.body
def test_password_reset_unknown_email_is_silent(client):
response = client.post(reverse("password_reset"), {"email": "onbekend@example.invalid"})
assert response.status_code == 302
assert len(mail.outbox) == 0
def test_demo_button_visible_by_default(client):
assert reverse("demo-login") in client.get(reverse("login")).content.decode("utf-8")
@@ -105,6 +113,7 @@ def test_demo_login_disabled_redirects(client):
def test_demo_button_hidden_when_disabled(client):
assert "demo-login" not in client.get(reverse("login")).content.decode("utf-8")
def test_demo_login_logs_in_and_seeds_environment(client):
from apps.jobs.models import Application, JobPosting, ScoreRun
from apps.profiles.models import SearchProfile
@@ -127,6 +136,7 @@ def test_demo_login_logs_in_and_seeds_environment(client):
dashboard = client.get(reverse("dashboard:today"))
assert dashboard.status_code == 200
def test_demo_login_is_idempotent(client):
from apps.jobs.models import JobPosting
@@ -135,6 +145,7 @@ def test_demo_login_is_idempotent(client):
client.post(reverse("demo-login"))
assert JobPosting.objects.count() == first
def test_demo_account_is_read_only_and_session_is_bounded(client):
from apps.profiles.models import SearchProfile
@@ -156,12 +167,14 @@ def test_demo_account_is_read_only_and_session_is_bounded(client):
assert alternative.is_active is False
assert client.session.get_expiry_age() <= settings.DEMO_SESSION_SECONDS
def test_demo_account_can_still_log_out(client):
client.post(reverse("demo-login"))
response = client.post(reverse("logout"))
assert response.status_code == 302
assert client.session.get("_auth_user_id") is None
def test_authenticated_demo_page_disables_mutating_forms(client):
client.post(reverse("demo-login"))
body = client.get(reverse("dashboard:today")).content.decode("utf-8")
@@ -169,6 +182,7 @@ def test_authenticated_demo_page_disables_mutating_forms(client):
assert "Publieke demo · alleen-lezen" in body
assert "data-demo-allow" in body
def test_entra_login_disabled_redirects(client):
response = client.post(reverse("entra-login"))
assert response.status_code == 302
+17 -1
View File
@@ -60,6 +60,7 @@ def test_premium_layout_uses_one_bounded_ultrawide_grid():
css = (settings.BASE_DIR / "static" / "css" / "premium.css").read_text(encoding="utf-8")
assert "--content-frame: 1720px" in css
assert "width: min(100%, var(--content-frame))" in css
assert "margin-inline: auto" in css
assert ".topbar-inner" in css
assert "--content-frame: 2200px" in css
assert "--content-frame: 2480px" in css
@@ -67,9 +68,24 @@ def test_premium_layout_uses_one_bounded_ultrawide_grid():
assert "minmax(650px, 880px) minmax(760px, 1fr)" in css
@pytest.mark.integration
@pytest.mark.django_db
def test_demo_banner_is_part_of_main_content_flow(client, user, settings):
settings.DEMO_MODE_ENABLED = True
settings.DEMO_READ_ONLY = True
settings.DEMO_USERNAME = user.username
client.force_login(user)
response = client.get(reverse("dashboard:today"))
body = response.content.decode()
assert response.status_code == 200
assert body.index('<main id="main"') < body.index('class="demo-mode-banner"')
assert 'role="status" aria-label="Publieke demo, alleen-lezen"' in body
def test_css_imports_share_the_release_cache_version():
css = (settings.BASE_DIR / "static" / "css" / "app.css").read_text(encoding="utf-8")
assert css.count("?v=0.3.12") == 7
assert css.count("?v=0.3.13") == 7
@pytest.mark.integration
+87
View File
@@ -0,0 +1,87 @@
from django.contrib.auth import get_user_model
from django.test import override_settings
from apps.core.auth_backends import EmailOrUsernameModelBackend
from apps.core.entra import (
EntraConfigError,
domain_allowed,
entra_enabled,
extract_identity,
load_config,
)
def test_auth_backend_rejects_missing_and_unknown_credentials(db):
backend = EmailOrUsernameModelBackend()
assert backend.authenticate(None, username=None, password=None) is None
assert backend.authenticate(None, username="unknown@example.invalid", password="secret") is None
def test_auth_backend_rejects_inactive_and_wrong_password(db):
user = get_user_model().objects.create_user(
username="inactive", email="inactive@example.invalid", password="correct", is_active=False
)
backend = EmailOrUsernameModelBackend()
assert backend.authenticate(None, username=user.email, password="wrong") is None
assert backend.authenticate(None, username=user.email, password="correct") is None
def test_auth_backend_handles_duplicate_email_without_ambiguous_login(db):
user_model = get_user_model()
first = user_model.objects.create_user(
username="first", email="shared@example.invalid", password="correct"
)
user_model.objects.create_user(
username="second", email="shared@example.invalid", password="correct"
)
backend = EmailOrUsernameModelBackend()
assert backend.authenticate(None, username="shared@example.invalid", password="correct") is None
assert backend.authenticate(None, username="first", password="correct") == first
@override_settings(ENTRA_ID_ENABLED=True)
def test_entra_enabled_reflects_configuration():
assert entra_enabled() is True
@override_settings(
ENTRA_ID_ENABLED=True, ENTRA_CLIENT_ID="", ENTRA_CLIENT_SECRET="", ENTRA_TENANT_ID=""
)
def test_entra_config_fails_closed_when_credentials_are_incomplete():
try:
load_config()
except EntraConfigError as exc:
assert "niet allemaal ingesteld" in str(exc)
else: # pragma: no cover - defensive assertion
raise AssertionError("Onvolledige Entra-configuratie werd geaccepteerd")
@override_settings(
ENTRA_CLIENT_ID="client",
ENTRA_CLIENT_SECRET="secret",
ENTRA_TENANT_ID="tenant",
ENTRA_SCOPES=["User.Read"],
ENTRA_ALLOWED_DOMAINS=["@Example.COM"],
ENTRA_REDIRECT_URI="https://example.invalid/callback",
)
def test_entra_config_normalizes_domains_and_identity_claims():
config = load_config()
assert config.authority.endswith("/tenant")
assert config.allowed_domains == ("example.com",)
assert domain_allowed(config, "person@example.com") is True
assert domain_allowed(config, "person@other.invalid") is False
assert extract_identity(
{"id_token_claims": {"preferred_username": " person@example.com ", "name": " Person "}}
) == ("person@example.com", "Person")
@override_settings(
ENTRA_CLIENT_ID="client",
ENTRA_CLIENT_SECRET="secret",
ENTRA_TENANT_ID="tenant",
ENTRA_ALLOWED_DOMAINS=[],
)
def test_entra_allows_any_domain_only_when_allowlist_is_empty():
assert domain_allowed(load_config(), "person@example.invalid") is True
+2 -2
View File
@@ -96,7 +96,7 @@ def test_public_https_origin_uses_external_port_not_internal_app_port() -> None:
assert 'app_public_port="${APP_PUBLIC_PORT:-}"' in deploy_script
assert 'app_public_port="443"' in deploy_script
assert 'write_env "PUBLIC_BASE_URL" "$app_base_url" 1' in deploy_script
assert 'https://$app_host:$app_port' not in deploy_script
assert "https://$app_host:$app_port" not in deploy_script
def test_health_endpoints_are_exempt_from_internal_https_redirect() -> None:
@@ -123,4 +123,4 @@ def test_deploy_uses_selected_environment_file_for_compose() -> None:
assert compose["x-app"]["env_file"] == ["${VACATURERADAR_ENV_FILE:-.env}"]
assert 'export VACATURERADAR_ENV_FILE="$env_file"' in deploy_script
assert 'docker compose --env-file "$env_file" -f "$compose_file" up -d --build' in deploy_script
assert 'waarde uit ${env_file}' in deploy_script
assert "waarde uit ${env_file}" in deploy_script
+27
View File
@@ -0,0 +1,27 @@
from django.core.cache import cache
from django.test import override_settings
from apps.core.services.automation import _runtime_heartbeat_state
from apps.core.tasks import RUNTIME_HEARTBEAT_KEY, record_runtime_heartbeat
@override_settings(CELERY_TASK_ALWAYS_EAGER=False)
def test_runtime_heartbeat_reports_real_worker_execution():
cache.delete(RUNTIME_HEARTBEAT_KEY)
assert _runtime_heartbeat_state()["healthy"] is False
timestamp = record_runtime_heartbeat()
state = _runtime_heartbeat_state()
assert cache.get(RUNTIME_HEARTBEAT_KEY) == timestamp
assert state["healthy"] is True
assert state["label"].startswith("Actief")
@override_settings(CELERY_TASK_ALWAYS_EAGER=True)
def test_runtime_heartbeat_is_honest_in_eager_mode():
assert _runtime_heartbeat_state() == {
"label": "Eager/lokaal",
"healthy": True,
"observed_at": None,
}