forked from retoor/devplacepy
feat: add structured data schemas, og tags, share button, and configurable site url
This commit is contained in:
+19
-3
@@ -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
@@ -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
@@ -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())}"
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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 "")
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user