feat: add structured data schemas, og tags, share button, and configurable site url

This commit is contained in:
2026-05-23 01:21:55 +00:00
parent 8eea7cc0c9
commit 77ad93a4bb
35 changed files with 437 additions and 62 deletions
+19 -3
View File
@@ -25,6 +25,7 @@ _TEST_DB.close()
os.environ["DEVPLACE_DATABASE_URL"] = f"sqlite:///{_TEST_DB.name}"
os.environ["SECRET_KEY"] = "test-secret-key"
os.environ["DEVPLACE_DISABLE_SERVICES"] = "1"
os.environ["DEVPLACE_RATE_LIMIT"] = "1000000"
def save_failure_screenshot(page, test_name):
@@ -117,14 +118,14 @@ def playwright_instance():
def browser(playwright_instance):
headless = os.environ.get("PLAYWRIGHT_HEADLESS", "1") == "1"
b = playwright_instance.chromium.launch(
headless=headless, slow_mo=300, args=["--window-size=1400,900"],
headless=headless, slow_mo=int(os.environ.get("PLAYWRIGHT_SLOW_MO", "0")), args=["--window-size=1400,900"],
)
yield b
b.close()
@pytest.fixture(scope="module")
def browser_context(browser):
ctx = browser.new_context(viewport={"width": 1400, "height": 900})
ctx = browser.new_context(viewport={"width": 1400, "height": 900}, permissions=["clipboard-read", "clipboard-write"])
yield ctx
ctx.close()
@@ -158,6 +159,21 @@ def login_user(page, user):
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
def assert_share_copies(page, expected_fragment):
from playwright.sync_api import expect
share = page.locator("button[data-share]").first
share.scroll_into_view_if_needed()
share.click()
expect(share).to_have_text("Copied!", timeout=3000)
expect(share).not_to_have_text("Copied!", timeout=3000)
try:
clip = page.evaluate("navigator.clipboard.readText()")
except Exception:
clip = None
if clip:
assert clip.startswith("http") and expected_fragment in clip, f"clipboard={clip!r}"
@pytest.fixture(scope="session")
def seeded_db(app_server):
"""Create test users once at session level using requests directly."""
@@ -192,7 +208,7 @@ def alice(page, seeded_db):
@pytest.fixture
def bob(browser, seeded_db):
ctx = browser.new_context(viewport={"width": 1400, "height": 900})
ctx = browser.new_context(viewport={"width": 1400, "height": 900}, permissions=["clipboard-read", "clipboard-write"])
p = ctx.new_page()
p.set_default_timeout(15000)
p.bring_to_front()
+13 -1
View File
@@ -1,4 +1,16 @@
from tests.conftest import BASE_URL
from tests.conftest import BASE_URL, assert_share_copies
def test_feed_card_share_button(alice):
page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").first.click()
page.fill("#post-content", "Feed share button test content")
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
assert_share_copies(page, "/posts/")
assert "/feed" in page.url
def test_feed_page_loads(alice):
+17 -3
View File
@@ -1,5 +1,5 @@
import time
from tests.conftest import BASE_URL
from tests.conftest import BASE_URL, assert_share_copies
def _set_cm_value(page, value):
@@ -124,17 +124,31 @@ def test_gist_voting(alice, app_server):
page, _ = alice
title = f"Vote Test {int(time.time())}"
_create_gist(page, title=title, source_code="vote_me = True")
star_btn = page.locator("button.gist-star-btn").first
star_btn = page.locator("form[action*='/votes/gist/'] button").first
original_text = star_btn.text_content()
original_stars = int(original_text.strip("\u2606 "))
star_btn.click()
page.wait_for_timeout(500)
star_btn = page.locator("button.gist-star-btn").first
star_btn = page.locator("form[action*='/votes/gist/'] button").first
new_text = star_btn.text_content()
new_stars = int(new_text.strip("\u2606 "))
assert new_stars == original_stars + 1
def test_gist_detail_has_sourcecode_schema(alice, app_server):
page, _ = alice
_create_gist(page, title=f"Schema Gist {int(time.time())}", source_code="x = 1")
scripts = page.locator('script[type="application/ld+json"]')
text = " ".join(scripts.nth(i).text_content() for i in range(scripts.count()))
assert "SoftwareSourceCode" in text
def test_gist_detail_share_button(alice, app_server):
page, _ = alice
_create_gist(page, title=f"Share Gist {int(time.time())}", source_code="x = 1")
assert_share_copies(page, "/gists/")
def test_profile_gists_tab(alice, app_server):
page, alice_user = alice
title = f"Profile Tab Test {int(time.time())}"
+2 -6
View File
@@ -17,12 +17,8 @@ def test_messages_search_input(alice):
def test_messages_empty_state(alice):
page, _ = alice
page.goto(f"{BASE_URL}/messages")
empty = page.locator("text=No conversations yet")
if empty.is_visible():
pass
else:
assert page.is_visible(".messages-layout")
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")
assert page.is_visible(".messages-layout") or page.is_visible("text=No conversations yet")
def test_messages_search_for_bob(alice):
+7 -1
View File
@@ -1,11 +1,17 @@
import pytest
from uuid import uuid4
from datetime import datetime, timezone
from tests.conftest import BASE_URL
from tests.conftest import BASE_URL, assert_share_copies
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
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 seed_news():
news_table = get_table("news")
for i in range(3):
+2 -6
View File
@@ -32,12 +32,8 @@ def test_notifications_bell_visible(alice):
def test_notifications_empty_state(alice):
page, _ = alice
page.goto(f"{BASE_URL}/notifications")
empty = page.locator("text=No notifications yet")
if empty.is_visible():
pass
else:
assert page.is_visible("h2:has-text('Notifications')")
page.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
assert page.is_visible("h2:has-text('Notifications')") or page.is_visible("text=No notifications yet")
def test_notifications_header_has_clear(alice):
+7 -9
View File
@@ -1,4 +1,4 @@
from tests.conftest import BASE_URL
from tests.conftest import BASE_URL, assert_share_copies
def create_post(page, topic="random", content="Test post content", title=None):
@@ -36,18 +36,17 @@ def test_post_author_info(alice):
def test_post_vote_button(alice):
page, _ = alice
create_post(page, "devlog", "Votable post")
vote_btn = page.locator("button:has-text('+0')").first
vote_btn = page.locator(".post-action-btn.vote-up").first
assert vote_btn.is_visible()
def test_post_vote_increment(alice):
page, _ = alice
create_post(page, "showcase", "Vote increment test")
vote_btn = page.locator("button").filter(has_text="+").first
vote_btn.click()
page.wait_for_timeout(500)
content = page.locator(".post-detail-actions").text_content()
assert "+1" in content
page.locator(".post-action-btn.vote-up").first.click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
count = page.locator(".post-vote-count").first.text_content().strip()
assert count == "1", f"expected vote count 1, got {count!r}"
def test_post_comments_section(alice):
@@ -102,8 +101,7 @@ def test_comment_form_elements(alice):
def test_share_button(alice):
page, _ = alice
create_post(page, "showcase", "Share button check")
share = page.locator("button:has-text('Share')")
assert share.is_visible()
assert_share_copies(page, "/posts/")
def test_back_to_feed_link(alice):
+16 -1
View File
@@ -1,4 +1,19 @@
from tests.conftest import BASE_URL
from tests.conftest import BASE_URL, assert_share_copies
def test_project_detail_share_button(alice):
page, _ = alice
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.locator("#create-project-btn").click()
page.fill("#title", "Share Project")
page.fill("#description", "A project created for the share button test")
page.fill("#release_date", "2026-06-01")
page.check("input[value='game']")
page.locator("#platforms-input").fill("PC")
page.locator("#platforms-input").press("Enter")
page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
assert_share_copies(page, "/projects/")
def test_projects_page_loads(alice):
+31
View File
@@ -0,0 +1,31 @@
import devplacepy.main as m
from starlette.testclient import TestClient
def test_rate_limit_blocks_excess(monkeypatch):
monkeypatch.setattr(m, "RATE_LIMIT", 3)
m._rate_limit_store.clear()
client = TestClient(m.app)
codes = [client.post("/", headers={"X-Real-IP": "9.9.9.9"}).status_code for _ in range(6)]
assert 429 in codes, codes
assert codes[-1] == 429
def test_rate_limit_is_per_ip(monkeypatch):
monkeypatch.setattr(m, "RATE_LIMIT", 2)
m._rate_limit_store.clear()
client = TestClient(m.app)
for _ in range(2):
client.post("/", headers={"X-Real-IP": "1.1.1.1"})
blocked = client.post("/", headers={"X-Real-IP": "1.1.1.1"}).status_code
other_ip = client.post("/", headers={"X-Real-IP": "2.2.2.2"}).status_code
assert blocked == 429
assert other_ip != 429
def test_get_requests_not_rate_limited(monkeypatch):
monkeypatch.setattr(m, "RATE_LIMIT", 2)
m._rate_limit_store.clear()
client = TestClient(m.app)
codes = [client.get("/robots.txt", headers={"X-Real-IP": "3.3.3.3"}).status_code for _ in range(5)]
assert all(c == 200 for c in codes), codes
+69 -2
View File
@@ -1,7 +1,6 @@
import pytest
import requests
BASE_URL = "http://127.0.0.1:10501"
from tests.conftest import BASE_URL
def test_robots_txt_exists(app_server):
@@ -146,3 +145,71 @@ def test_x_robots_tag_header(app_server):
def test_x_content_type_options_header(app_server):
r = requests.get(f"{BASE_URL}/feed", allow_redirects=True)
assert r.headers.get("X-Content-Type-Options") == "nosniff"
def _seed_news():
from datetime import datetime, timezone
from devplacepy.database import get_table
from devplacepy.utils import generate_uid, make_combined_slug
uid = generate_uid()
slug = make_combined_slug("SEO Test News Article", uid)
get_table("news").insert({
"uid": uid,
"slug": slug,
"title": "SEO Test News Article",
"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 test_robots_disallows_admin_and_uploads(app_server):
r = requests.get(f"{BASE_URL}/robots.txt")
assert "Disallow: /admin/" in r.text
assert "Disallow: /uploads/" in r.text
def test_sitemap_headers_and_static_entries(app_server):
r = requests.get(f"{BASE_URL}/sitemap.xml")
assert r.headers["content-type"].startswith("application/xml")
assert "cache-control" in {k.lower() for k in r.headers}
assert f"{BASE_URL}/gists" in r.text
def test_sitemap_includes_published_news(app_server):
slug, _ = _seed_news()
r = requests.get(f"{BASE_URL}/sitemap.xml")
assert f"/news/{slug}" in r.text
def test_feed_canonical_preserves_pagination(page, app_server):
page.goto(f"{BASE_URL}/feed?page=2", wait_until="domcontentloaded")
href = page.locator('link[rel="canonical"]').get_attribute("href")
assert href.endswith("?page=2"), href
def test_feed_canonical_drops_non_pagination_params(page, app_server):
page.goto(f"{BASE_URL}/feed?sort=new", wait_until="domcontentloaded")
href = page.locator('link[rel="canonical"]').get_attribute("href")
assert href.endswith("/feed"), href
def test_default_og_image_is_raster(page, app_server):
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
og = page.locator('meta[property="og:image"]').get_attribute("content")
assert og.endswith(".png"), og
def test_news_detail_has_newsarticle_schema(page, app_server):
slug, _ = _seed_news()
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"
+49
View File
@@ -0,0 +1,49 @@
import types
import devplacepy.seo as seo
import devplacepy.attachments as attach
def test_truncate_no_overflow():
assert len(seo.truncate("x" * 200)) <= 160
assert seo.truncate("short text") == "short text"
assert seo.truncate("") == ""
def test_software_application_url_is_per_project():
schema = seo.software_application_schema({"uid": "p1", "slug": "p1-foo", "title": "P"}, "https://x.test")
assert schema["url"] == "https://x.test/projects/p1-foo"
def test_schema_types():
assert seo.organization_schema("https://x.test")["@type"] == "Organization"
assert seo.news_article_schema({"uid": "n1", "title": "T", "synced_at": "2026-01-01"}, "https://x.test")["@type"] == "NewsArticle"
assert seo.software_source_code_schema({"uid": "g1", "title": "G", "language": "python"}, "https://x.test")["@type"] == "SoftwareSourceCode"
def test_news_article_publisher_is_organization():
schema = seo.news_article_schema({"uid": "n1", "title": "T", "synced_at": "2026-01-01"}, "https://x.test")
assert schema["publisher"]["@type"] == "Organization"
def test_site_url_prefers_env(monkeypatch):
monkeypatch.setattr(seo, "SITE_URL", "https://configured.test")
assert seo.site_url(None) == "https://configured.test"
def test_site_url_falls_back_to_request(monkeypatch):
monkeypatch.setattr(seo, "SITE_URL", "")
request = types.SimpleNamespace(base_url="http://fallback.test/")
assert seo.site_url(request) == "http://fallback.test"
def test_upload_allowlist_excludes_dangerous_types():
assert ".svg" not in attach.ALLOWED_UPLOAD_TYPES
assert ".html" not in attach.ALLOWED_UPLOAD_TYPES
assert ".png" in attach.ALLOWED_UPLOAD_TYPES
assert ".svg" not in attach.POST_IMAGE_EXTENSIONS
def test_detect_mime_neutralizes_dangerous_types():
assert attach._detect_mime(b"", "x.svg") == "application/octet-stream"
assert attach._detect_mime(b"", "x.html") == "application/octet-stream"
assert attach._detect_mime(b"", "x.png") == "image/png"
+1 -1
View File
@@ -40,7 +40,7 @@ def test_services_sidebar_link(page, seeded_db):
_promote_to_admin(user["username"])
login_user(page, user)
page.goto(f"{BASE_URL}/admin/services", wait_until="domcontentloaded")
link = page.locator(f"a[href='/admin/services']")
link = page.locator("a.sidebar-link[href='/admin/services']")
assert link.is_visible()
assert "active" in (link.get_attribute("class") or "")
+71
View File
@@ -0,0 +1,71 @@
import io
import time
import requests
from PIL import Image
from tests.conftest import BASE_URL
def _session():
s = requests.Session()
name = f"up_{int(time.time() * 1000)}"
s.post(f"{BASE_URL}/auth/signup", data={
"username": name,
"email": f"{name}@test.dev",
"password": "secret123",
"confirm_password": "secret123",
}, allow_redirects=True)
return s
def _png_bytes():
buf = io.BytesIO()
Image.new("RGB", (4, 4), (255, 0, 0)).save(buf, "PNG")
return buf.getvalue()
def test_upload_allowed_png(app_server):
s = _session()
r = s.post(f"{BASE_URL}/uploads/upload", files={"file": ("x.png", _png_bytes(), "image/png")})
assert r.status_code == 201, r.text
assert "url" in r.json()
def test_upload_rejects_svg(app_server):
s = _session()
r = s.post(f"{BASE_URL}/uploads/upload", files={"file": ("x.svg", b"<svg/>", "image/svg+xml")})
assert r.status_code == 415
def test_upload_rejects_html(app_server):
s = _session()
r = s.post(f"{BASE_URL}/uploads/upload", files={"file": ("x.html", b"<html></html>", "text/html")})
assert r.status_code == 415
def test_upload_rejects_oversize(app_server):
s = _session()
big = b"\x89PNG\r\n" + b"\x00" * (11 * 1024 * 1024)
r = s.post(f"{BASE_URL}/uploads/upload", files={"file": ("big.png", big, "image/png")})
assert r.status_code == 413
def test_uploaded_file_served_as_attachment(app_server):
s = _session()
r = s.post(f"{BASE_URL}/uploads/upload", files={"file": ("x.png", _png_bytes(), "image/png")})
url = r.json()["url"]
served = s.get(f"{BASE_URL}{url}")
assert served.status_code == 200
assert served.headers.get("Content-Disposition") == "attachment"
def test_upload_requires_login(app_server):
r = requests.post(f"{BASE_URL}/uploads/upload", files={"file": ("x.png", _png_bytes(), "image/png")}, allow_redirects=False)
assert r.status_code in (302, 303)
def test_delete_own_allowed_other_user_forbidden(app_server):
alice = _session()
uid = alice.post(f"{BASE_URL}/uploads/upload", files={"file": ("x.png", _png_bytes(), "image/png")}).json()["uid"]
bob = _session()
assert bob.delete(f"{BASE_URL}/uploads/delete/{uid}").status_code == 403
assert alice.delete(f"{BASE_URL}/uploads/delete/{uid}").status_code == 200