Files
devplacepy/tests/api/admin/media/purge.py
T
retoorandClaude Sonnet 5 3c69de9d55 Add Happy 404, featured/related sidebars, and next-post nav to the post page
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
2026-09-08 03:44:30 +02:00

141 lines
4.9 KiB
Python

# retoor <retoor@molodetz.nl>
import io
import time
import uuid
import requests
from PIL import Image
from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS
from devplacepy.database import get_table, set_setting
JSON_media = {"Accept": "application/json"}
def _png_bytes_media(color=(200, 30, 30)):
buf = io.BytesIO()
Image.new("RGB", (8, 8), color).save(buf, "PNG")
return buf.getvalue()
def _signup_media(prefix="media"):
s = requests.Session()
name = f"{prefix}_{uuid.uuid4().hex[:10]}"
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@test.devplace",
"password": "secret123",
"confirm_password": "secret123",
"birth_date": "1990-01-01",
"accept_terms": "1",
},
allow_redirects=True,
)
return s, name
def _login_media(seeded_db, who):
s = requests.Session()
creds = seeded_db[who]
s.post(
f"{BASE_URL}/auth/login",
data={"email": creds["email"], "password": creds["password"]},
allow_redirects=True,
)
return s
def _upload_media(s, name="pic.png", content=None, mime="image/png"):
files = {"file": (name, content if content is not None else _png_bytes_media(), mime)}
r = s.post(f"{BASE_URL}/uploads/upload", files=files)
assert r.status_code == 201, r.text
return r.json()["uid"]
def _create_post_media(s, attachment_uids, title="media post"):
r = s.post(
f"{BASE_URL}/posts/create",
data={
"content": "A post that carries media for the gallery tests.",
"title": title,
"topic": "random",
"attachment_uids": attachment_uids,
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert "/posts/" in r.url, r.url
return r.url
def _create_project_media(s, attachment_uids, title="Media Project"):
r = s.post(
f"{BASE_URL}/projects/create",
data={
"title": title,
"description": "Project with media for gallery tests.",
"project_type": "software",
"platforms": "linux",
"status": "In Development",
"attachment_uids": attachment_uids,
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert "/projects/" in r.url, r.url
return r.url
def _media_json(session, username):
r = session.get(f"{BASE_URL}/profile/{username}?tab=media", headers=JSON_media)
assert r.status_code == 200, r.text[:300]
return r.json()
def _media_uids(session, username):
return [m["uid"] for m in _media_json(session, username)["media"]]
def _seed_media_via_browser(page, title="ui media"):
up = page.request.post(
f"{BASE_URL}/uploads/upload",
multipart={
"file": {
"name": "pic.png",
"mimeType": "image/png",
"buffer": _png_bytes_media(),
}
},
)
assert up.ok, up.text()
uid = up.json()["uid"]
post = page.request.post(
f"{BASE_URL}/posts/create",
form={
"content": "media for the ui gallery test",
"title": title,
"topic": "random",
"attachment_uids": uid,
},
)
assert post.ok, post.text()
return uid
def _media_uids_via_page(page, username):
resp = page.request.get(f"{BASE_URL}/profile/{username}?tab=media", headers=JSON_media)
return [m["uid"] for m in resp.json()["media"]]
def test_purge_requires_admin(seeded_db):
owner, name = _signup_media()
uid = _upload_media(owner)
_create_post_media(owner, uid)
owner.post(f"{BASE_URL}/media/{uid}/delete", headers=JSON_media)
member = _login_media(seeded_db, "bob")
r = member.post(f"{BASE_URL}/admin/media/{uid}/purge", allow_redirects=False)
assert r.status_code in (302, 303)
def test_admin_purge_hard_deletes(seeded_db):
owner, name = _signup_media()
uid = _upload_media(owner)
_create_post_media(owner, uid)
file_url = next(m["url"] for m in _media_json(owner, name)["media"] if m["uid"] == uid)
owner.post(f"{BASE_URL}/media/{uid}/delete", headers=JSON_media)
admin = _login_media(seeded_db, "alice")
assert owner.get(f"{BASE_URL}{file_url}").status_code == 200
r = admin.post(f"{BASE_URL}/admin/media/{uid}/purge", headers=JSON_media)
assert r.status_code == 200
# row gone (not in trash, cannot restore) and file removed from disk
trash = admin.get(f"{BASE_URL}/admin/media", headers=JSON_media).json()
assert all(m["uid"] != uid for m in trash["media"])
set_setting("happy_404_enabled", "0")
time.sleep(CACHE_VERSION_PROPAGATION_SECONDS)
try:
assert owner.get(f"{BASE_URL}{file_url}").status_code == 404
finally:
set_setting("happy_404_enabled", "1")
time.sleep(CACHE_VERSION_PROPAGATION_SECONDS)