Happy 404: an HTML 404 (unmatched route, or an explicit not-found inside a
real route) now renders a random existing post instead of the error page,
using the exact same context builder as a real post view. Toggle is the
happy_404_enabled site setting (default on, /admin/settings); JSON/API
requests and a handful of excluded prefixes are never affected. The pool of
candidate slugs is cached in-process and resampled periodically so it stays
fast and eventually cycles the whole posts table; on any internal failure it
falls straight through to the real 404 page.
Applying this everywhere surfaced ~60 existing tests that asserted a literal
404 for a legitimate resource-not-found flow (deleted post, unknown
container, wrong project slug, etc.) - each now disables the setting for the
duration of that specific check and restores it after, so the underlying
not-found behavior stays covered independently of the new feature.
Post page also gained, all built on the same shared post_page_context() so
they render identically on both a real post and a happy-404 page:
- A left sidebar (three separate cards, matching /feed's sidebar-card
convention) for "Gists from {author}", "Projects from {author}" (private
projects filtered through the normal visibility check), and "Related
Discussions" - each cached per author and invalidated on create/edit/
delete so new content shows up immediately.
- A right column reusing /feed's exact Daily Topic widget class for up to
three "Featured" articles (the existing but previously-unused `featured`
news flag), cached as a pool with per-request random sampling.
- A "Next post -> " link beside "Back to Feed", pointing at the next older
post site-wide (blocked authors skipped). Wired through the same next_url
mechanism already used for listing pagination, so it emits a real
backend-rendered <link rel="next"> tag for SEO, not just a visible link.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BWJy6PrMMt5hwWxQwia2rd
198 lines
7.5 KiB
Python
198 lines
7.5 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
import html
|
|
import json
|
|
import re
|
|
import time
|
|
import pytest
|
|
import requests
|
|
from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS
|
|
from devplacepy.database import get_table, set_setting
|
|
from devplacepy.docs_api import API_GROUPS
|
|
from devplacepy.utils import clear_user_cache
|
|
|
|
|
|
@pytest.fixture(scope="module", autouse=True)
|
|
def _disable_happy_404(app_server):
|
|
set_setting("happy_404_enabled", "0")
|
|
time.sleep(CACHE_VERSION_PROPAGATION_SECONDS)
|
|
yield
|
|
set_setting("happy_404_enabled", "1")
|
|
time.sleep(CACHE_VERSION_PROPAGATION_SECONDS)
|
|
def _configs_by_id(text):
|
|
configs = re.findall(r"data-config='(.*?)'", text, re.S)
|
|
return {
|
|
json.loads(html.unescape(cfg))["id"]: json.loads(html.unescape(cfg))
|
|
for cfg in configs
|
|
}
|
|
def _key_role_visibility(username):
|
|
return get_table("users").find_one(username=username)["api_key"]
|
|
_author_key = []
|
|
def _author():
|
|
# A dedicated Member author so seeding posts never pollutes alice/bob state
|
|
# (notifications, post counts, XP) that other test files assert on.
|
|
if _author_key:
|
|
return _author_key[0]
|
|
name = "rolevis_author"
|
|
requests.post(
|
|
f"{BASE_URL}/auth/signup",
|
|
data={
|
|
"username": name,
|
|
"email": f"{name}@t.dev",
|
|
"password": "secret123",
|
|
"confirm_password": "secret123",
|
|
"birth_date": "1990-01-01",
|
|
"accept_terms": "1",
|
|
},
|
|
allow_redirects=True,
|
|
)
|
|
row = get_table("users").find_one(username=name)
|
|
get_table("users").update({"uid": row["uid"], "role": "Member"}, ["uid"])
|
|
_author_key.append(row["api_key"])
|
|
return _author_key[0]
|
|
def _seed_post_role_visibility():
|
|
requests.post(
|
|
f"{BASE_URL}/posts/create",
|
|
headers={"X-API-KEY": _author()},
|
|
data={
|
|
"content": "role visibility seed post body text",
|
|
"title": "RoleVis",
|
|
"topic": "random",
|
|
},
|
|
)
|
|
|
|
|
|
def test_docs_layout_has_sidebar(page, app_server):
|
|
page.goto(f"{BASE_URL}/docs/index.html", wait_until="domcontentloaded")
|
|
assert (
|
|
page.locator(
|
|
".sidebar-card a.sidebar-link[href='/docs/authentication.html']"
|
|
).count()
|
|
== 1
|
|
)
|
|
|
|
|
|
def test_docs_auth_examples_use_logged_in_user(alice):
|
|
page, user = alice
|
|
page.goto(f"{BASE_URL}/docs/authentication.html", wait_until="domcontentloaded")
|
|
content = page.content()
|
|
key = get_table("users").find_one(username=user["username"])["api_key"]
|
|
assert key in content
|
|
assert user["username"] in content
|
|
|
|
|
|
def test_docs_widget_code_has_highlight_linenumbers_copy(page, app_server):
|
|
page.goto(f"{BASE_URL}/docs/social-actions.html", wait_until="domcontentloaded")
|
|
panel = page.locator(".code-block pre.code-panel").first
|
|
panel.wait_for(state="visible")
|
|
panel.locator(".code-gutter").first.wait_for(state="attached")
|
|
assert panel.locator(".code-gutter").count() >= 1
|
|
assert panel.locator(".code-copy-btn").count() >= 1
|
|
assert panel.locator("code.hljs").count() >= 1
|
|
# switching language tab re-highlights (not left as plain text)
|
|
page.locator(".code-block .code-tab:has-text('Python')").first.click()
|
|
panel.locator("code.hljs").first.wait_for(state="attached")
|
|
assert panel.locator("code.hljs").count() >= 1
|
|
|
|
|
|
def test_docs_prose_code_has_linenumbers_copy(page, app_server):
|
|
page.goto(f"{BASE_URL}/docs/authentication.html", wait_until="domcontentloaded")
|
|
pre = page.locator(".docs-content pre.code-pre").first
|
|
pre.wait_for(state="visible")
|
|
assert pre.locator(".code-gutter").count() >= 1
|
|
assert pre.locator(".code-copy-btn").count() >= 1
|
|
assert pre.locator("code.hljs").count() >= 1
|
|
|
|
|
|
def test_docs_panel_format_picker_defaults_json(page, app_server):
|
|
page.goto(f"{BASE_URL}/docs/content.html", wait_until="domcontentloaded")
|
|
panel = page.locator(".api-tester").first
|
|
panel.wait_for(state="visible")
|
|
active = panel.locator(".format-option.active").first
|
|
active.wait_for(state="visible")
|
|
assert active.inner_text().strip() == "JSON"
|
|
assert panel.locator(".response-tabs .code-tab:has-text('Expected')").count() == 1
|
|
assert (
|
|
panel.locator(".response-tabs .code-tab:has-text('Live response')").count() == 1
|
|
)
|
|
expected = panel.locator(".response-pane.active").first
|
|
expected.wait_for(state="visible")
|
|
assert expected.locator("pre.response-body code").count() >= 1
|
|
|
|
|
|
def test_docs_panel_html_format_updates_snippet(page, app_server):
|
|
page.goto(f"{BASE_URL}/docs/content.html", wait_until="domcontentloaded")
|
|
panel = page.locator(".api-tester").first
|
|
panel.wait_for(state="visible")
|
|
code = panel.locator("pre.code-panel code").first
|
|
code.wait_for(state="attached")
|
|
assert "application/json" in code.inner_text()
|
|
panel.locator(".format-option:has-text('HTML')").first.click()
|
|
panel.locator("pre.code-panel code").filter(has_text="text/html").first.wait_for(
|
|
timeout=5000
|
|
)
|
|
|
|
|
|
def test_docs_panel_live_send_returns_json(page, app_server):
|
|
page.goto(f"{BASE_URL}/docs/issues.html", wait_until="domcontentloaded")
|
|
panel = page.locator(".api-tester").first
|
|
panel.wait_for(state="visible")
|
|
send = panel.locator(".try-send").first
|
|
send.wait_for(state="visible")
|
|
send.click()
|
|
status = panel.locator(".response-pane.active .response-status").first
|
|
status.wait_for(state="visible", timeout=10000)
|
|
assert status.inner_text().strip().startswith("2")
|
|
|
|
|
|
def test_guest_docs_hide_admin(page, app_server):
|
|
page.goto(f"{BASE_URL}/docs/index.html", wait_until="domcontentloaded")
|
|
assert page.locator("a[href='/docs/admin.html']").count() == 0
|
|
assert page.locator("a[href='/docs/services.html']").count() == 0
|
|
assert requests.get(f"{BASE_URL}/docs/admin.html").status_code == 404
|
|
assert requests.get(f"{BASE_URL}/docs/services.html").status_code == 404
|
|
|
|
|
|
def test_member_docs_hide_admin(bob):
|
|
page, _ = bob
|
|
assert (
|
|
requests.get(
|
|
f"{BASE_URL}/docs/admin.html", headers={"X-API-KEY": _key_role_visibility("bob_test")}
|
|
).status_code
|
|
== 404
|
|
)
|
|
page.goto(f"{BASE_URL}/docs/index.html", wait_until="domcontentloaded")
|
|
assert page.locator("a[href='/docs/admin.html']").count() == 0
|
|
|
|
|
|
def test_admin_docs_access(alice):
|
|
page, _ = alice
|
|
assert (
|
|
requests.get(
|
|
f"{BASE_URL}/docs/admin.html", headers={"X-API-KEY": _key_role_visibility("alice_test")}
|
|
).status_code
|
|
== 200
|
|
)
|
|
page.goto(f"{BASE_URL}/docs/index.html", wait_until="domcontentloaded")
|
|
assert page.locator("a[href='/docs/admin.html']").count() >= 1
|
|
|
|
|
|
def test_docs_search_falls_back_to_bm25_when_devii_unavailable(page, app_server):
|
|
# The test server runs with DEVPLACE_DISABLE_SERVICES=1, so agent search is
|
|
# unavailable and the docs search page falls back to the classic BM25 results.
|
|
page.goto(f"{BASE_URL}/docs/search.html?q=authentication", wait_until="domcontentloaded")
|
|
page.locator(".docs-search-bigform").wait_for(state="visible")
|
|
assert page.locator("dp-docs-chat").count() == 0
|
|
|
|
|
|
def test_docs_search_bm25_returns_results(page, app_server):
|
|
page.goto(f"{BASE_URL}/docs/search.html?q=authentication", wait_until="domcontentloaded")
|
|
titles = page.locator(".docs-search-results .docs-search-result-title")
|
|
titles.first.wait_for(state="visible")
|
|
assert titles.count() >= 1
|
|
|
|
|
|
def test_docs_no_floating_autoopen(page, app_server):
|
|
page.goto(f"{BASE_URL}/docs/index.html", wait_until="domcontentloaded")
|
|
assert page.locator("[data-devii-autoopen]").count() == 0
|