Files
devplacepy/tests/e2e/news.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

400 lines
13 KiB
Python

# retoor <retoor@molodetz.nl>
import time
import pytest
from uuid import uuid4
from datetime import datetime, timedelta, timezone
from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS, assert_share_copies
from devplacepy.database import get_table, set_setting
from devplacepy.utils import make_combined_slug
def _seed_news_paginated(count):
news_table = get_table("news")
base = datetime.now(timezone.utc)
marker = uuid4().hex[:8]
titles = []
for i in range(count):
uid = str(uuid4())
title = f"Pag News {marker} {i:02d}"
titles.append(title)
news_table.insert(
{
"deleted_at": None,
"deleted_by": None,
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": f"pag_{marker}_{i}",
"grade": 10,
"status": "published",
"show_on_landing": 0,
"source_name": "PagSource",
"url": "https://example.com",
"description": "paginated news",
"synced_at": (base - timedelta(seconds=i)).isoformat(),
}
)
return titles
def seed_news():
news_table = get_table("news")
for i in range(3):
eid = f"test_news_{i}"
if news_table.find_one(external_id=eid):
continue
uid = str(uuid4())
title = f"News Test Article {i}"
slug = make_combined_slug(title, uid)
news_table.insert(
{
"deleted_at": None,
"deleted_by": None,
"uid": uid,
"slug": slug,
"title": title,
"external_id": eid,
"grade": 8 + i,
"status": "published",
"show_on_landing": 1,
"source_name": "TestSource",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": f"Description for test article {i}.",
}
)
@pytest.fixture
def news_article(app_server):
seed_news()
article = get_table("news").find_one(external_id="test_news_0")
return article
import requests
from tests.conftest import BASE_URL
def _seed_news_seo():
from datetime import datetime, timezone
from devplacepy.database import get_table
from devplacepy.utils import generate_uid, make_combined_slug
uid = generate_uid()
title = f"SEO Test News Article {uid.split('-')[-1]}"
slug = make_combined_slug(title, uid)
get_table("news").insert(
{
"deleted_at": None,
"deleted_by": None,
"uid": uid,
"slug": slug,
"title": title,
"description": "A seeded news article for SEO tests.",
"content": "Body content for the seeded article.",
"url": "https://example.com/article",
"source_name": "ExampleSource",
"status": "published",
"synced_at": datetime.now(timezone.utc).isoformat(),
"show_on_landing": 0,
"grade": 8,
}
)
return slug, uid
def _seed_owner():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
uid = str(uuid4())
get_table("users").insert(
{
"uid": uid,
"username": f"seo_{uid[:8]}",
"terms_version": "1",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def _seed_post_seo(image=None):
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Post", uid)
get_table("posts").insert(
{
"deleted_at": None,
"deleted_by": None,
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Post",
"content": "Body text for the SEO detail post.",
"topic": "general",
"project_uid": None,
"image": image,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_gist():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Gist", uid)
get_table("gists").insert(
{
"deleted_at": None,
"deleted_by": None,
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Gist",
"description": "Gist description for SEO tests.",
"source_code": "print('seo')",
"language": "python",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_project():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Project", uid)
get_table("projects").insert(
{
"deleted_at": None,
"deleted_by": None,
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Project",
"description": "Project description for SEO tests.",
"project_type": "software",
"platforms": "Linux",
"status": "Released",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_news_image(news_uid):
from devplacepy.database import get_table
get_table("news_images").insert(
{
"deleted_at": None,
"deleted_by": None,
"news_uid": news_uid,
"url": "https://example.com/seo-news-image.jpg",
"is_placeholder": 0,
}
)
def _seed_feed_posts(count):
from datetime import datetime, timezone, timedelta
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
topic = f"seopag{owner[:8]}"
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
posts = get_table("posts")
for i in range(count):
uid = str(uuid4())
posts.insert(
{
"deleted_at": None,
"deleted_by": None,
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(f"seo pag {i}", uid),
"title": None,
"content": f"seo pag post {i}",
"topic": topic,
"project_uid": None,
"image": None,
"stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
}
)
return topic
def test_news_detail_share_button(page, news_article):
slug = news_article.get("slug") or news_article["uid"]
page.goto(f"{BASE_URL}/news/{slug}", wait_until="domcontentloaded")
assert_share_copies(page, "/news/")
def test_news_listing_loads(page, app_server):
seed_news()
page.goto(f"{BASE_URL}/news", wait_until="domcontentloaded")
assert page.is_visible("text=Developer News")
assert page.is_visible("text=News Test Article")
def test_news_listing_authenticated(alice):
page, _ = alice
seed_news()
page.goto(f"{BASE_URL}/news", wait_until="domcontentloaded")
assert page.is_visible("text=Developer News")
def test_news_detail_loads(page, news_article):
slug = news_article.get("slug") or news_article["uid"]
page.goto(f"{BASE_URL}/news/{slug}", wait_until="domcontentloaded")
assert page.is_visible("text=News Test Article")
assert page.is_visible("text=Comments")
def test_news_detail_404(page, app_server):
set_setting("happy_404_enabled", "0")
time.sleep(CACHE_VERSION_PROPAGATION_SECONDS)
try:
page.goto(f"{BASE_URL}/news/nonexistent-article", wait_until="domcontentloaded")
assert page.is_visible("text=not found", timeout=5000)
finally:
set_setting("happy_404_enabled", "1")
time.sleep(CACHE_VERSION_PROPAGATION_SECONDS)
def test_news_comment(alice, news_article):
page, _ = alice
slug = news_article.get("slug") or news_article["uid"]
page.goto(f"{BASE_URL}/news/{slug}", wait_until="domcontentloaded")
page.fill("textarea[name='content']", "Test comment on news article")
page.click("button:has-text('Post')")
page.wait_for_url(f"{BASE_URL}/news/{slug}**", wait_until="domcontentloaded")
assert page.is_visible("text=Test comment on news article")
def test_news_pagination_first_page(page, app_server):
_seed_news_paginated(30)
page.goto(f"{BASE_URL}/news", wait_until="domcontentloaded")
assert page.locator(".news-card").count() == 25
assert page.is_visible(".load-more-wrap")
def test_news_pagination_load_more(page, app_server):
titles = _seed_news_paginated(30)
page.goto(f"{BASE_URL}/news", wait_until="domcontentloaded")
newest = titles[0]
assert page.is_visible(f".news-card-title:has-text('{newest}')")
page.click(".load-more-wrap a")
page.wait_for_url(lambda url: "before=" in url, wait_until="domcontentloaded")
assert not page.is_visible(f".news-card-title:has-text('{newest}')")
assert page.locator(".news-card").count() >= 1
def test_news_detail_guest(page, news_article):
slug = news_article.get("slug") or news_article["uid"]
page.goto(f"{BASE_URL}/news/{slug}", wait_until="domcontentloaded")
assert page.is_visible("text=News Test Article")
assert page.is_visible("text=Comments")
assert page.is_visible("text=Read on TestSource")
def test_news_detail_has_newsarticle_schema(page, app_server):
slug, _ = _seed_news_seo()
page.goto(f"{BASE_URL}/news/{slug}", wait_until="domcontentloaded")
scripts = page.locator('script[type="application/ld+json"]')
text = " ".join(scripts.nth(i).text_content() for i in range(scripts.count()))
assert "NewsArticle" in text
assert (
page.locator('meta[property="og:type"]').get_attribute("content") == "article"
)
def test_news_detail_image_has_alt_text(page, app_server):
slug, uid = _seed_news_seo()
_seed_news_image(uid)
page.goto(f"{BASE_URL}/news/{slug}", wait_until="domcontentloaded")
alt = page.locator(".news-detail-image img").get_attribute("alt")
assert alt and alt.strip()
def _seed_news_with_comments():
owner = str(uuid4())
get_table("users").insert(
{
"uid": owner,
"username": f"ncseed_{owner[:8]}",
"terms_version": "1",
"email": f"{owner[:8]}@nc.seed",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
uid = str(uuid4())
marker = f"newscom-{uid[:8]}"
get_table("news").insert(
{
"deleted_at": None,
"deleted_by": None,
"uid": uid,
"slug": make_combined_slug(marker, uid),
"title": marker,
"description": "News with comment hierarchy.",
"content": "Body.",
"url": "https://example.com/nc",
"source_name": "NCSource",
"external_id": f"nc_{uid[:8]}",
"status": "published",
"show_on_landing": 0,
"grade": 10,
"synced_at": datetime.now(timezone.utc).isoformat(),
}
)
parent = str(uuid4())
base = datetime.now(timezone.utc)
rows = [
(f"{marker}-excluded", str(uuid4()), None, -2),
(f"{marker}-flat", str(uuid4()), None, -1),
(f"{marker}-parent", parent, None, 0),
(f"{marker}-reply", str(uuid4()), parent, 1),
]
comments = get_table("comments")
for content, cuid, par, off in rows:
comments.insert(
{
"deleted_at": None,
"deleted_by": None,
"uid": cuid,
"target_type": "news",
"target_uid": uid,
"user_uid": owner,
"content": content,
"parent_uid": par,
"created_at": (base + timedelta(seconds=off)).isoformat(),
}
)
return marker
def test_news_list_preserves_comment_hierarchy(page, app_server):
from playwright.sync_api import expect
marker = _seed_news_with_comments()
page.goto(f"{BASE_URL}/news", wait_until="domcontentloaded")
card = page.locator(".news-card").filter(has_text=marker).first
card.wait_for(state="visible")
previews = card.locator(".post-card-comments .comment-text")
expect(previews).to_have_count(3)
expect(card.locator(".post-card-comments")).not_to_contain_text(f"{marker}-excluded")
nested = card.locator(".post-card-comments .comment-replies .comment-text")
expect(nested).to_contain_text(f"{marker}-reply")