Update
Some checks failed
DevPlace CI / test (push) Failing after 2m53s

This commit is contained in:
retoor 2026-05-23 03:21:55 +02:00
parent 22e066a202
commit b23f655389
35 changed files with 437 additions and 62 deletions

View File

@ -18,8 +18,7 @@ jobs:
- name: Install dependencies
run: |
pip install -e .
pip install playwright pytest-xdist
pip install -e ".[dev]"
python -m playwright install chromium --with-deps
- name: Run integration tests

6
.gitignore vendored
View File

@ -5,3 +5,9 @@ __pycache__/
devplace.db*
.pytest_cache/
.opencode
devplacepy/static/uploads/attachments/
devplacepy/static/uploads/*.png
devplacepy/static/uploads/*.jpg
devplacepy/static/uploads/*.jpeg
devplacepy/static/uploads/*.gif
devplacepy/static/uploads/*.webp

View File

@ -19,4 +19,4 @@ EXPOSE 10500
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=10s \
CMD curl -f http://localhost:10500/ || exit 1
CMD ["uvicorn", "devplacepy.main:app", "--host", "0.0.0.0", "--port", "10500", "--workers", "4", "--backlog", "8192"]
CMD ["uvicorn", "devplacepy.main:app", "--host", "0.0.0.0", "--port", "10500", "--workers", "4", "--backlog", "8192", "--proxy-headers", "--forwarded-allow-ips", "*"]

View File

@ -15,7 +15,7 @@ dev:
uvicorn devplacepy.main:app --reload --host 0.0.0.0 --port 10500 --backlog 4096
prod:
uvicorn devplacepy.main:app --host 0.0.0.0 --port 10500 --workers 2 --backlog 8192
uvicorn devplacepy.main:app --host 0.0.0.0 --port 10500 --workers 2 --backlog 8192 --proxy-headers --forwarded-allow-ips '*'
test:
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/ -v --tb=line -x

View File

@ -11,3 +11,4 @@ DATABASE_URL = environ.get("DEVPLACE_DATABASE_URL", f"sqlite:///{BASE_DIR / 'dev
SECRET_KEY = environ.get("SECRET_KEY", "devplace-secret-key-change-in-production")
SESSION_MAX_AGE = 86400 * 7
PORT = 10500
SITE_URL = environ.get("DEVPLACE_SITE_URL", "").rstrip("/")

View File

@ -22,7 +22,7 @@ logging.basicConfig(
logger = logging.getLogger(__name__)
_rate_limit_store = defaultdict(list)
RATE_LIMIT = 60
RATE_LIMIT = int(os.environ.get("DEVPLACE_RATE_LIMIT", "60"))
RATE_WINDOW = 60
class UploadStaticFiles(StaticFiles):

View File

@ -6,7 +6,7 @@ from devplacepy.database import get_table, load_comments, get_vote_counts, resol
from devplacepy.attachments import get_attachments, delete_target_attachments
from devplacepy.templating import templates
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine, software_source_code_schema
logger = logging.getLogger(__name__)
router = APIRouter()
@ -103,12 +103,13 @@ async def gist_detail(request: Request, gist_slug: str):
request,
title=gist.get("title", "Gist"),
description=gist.get("description", "")[:160],
og_type="article",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Gists", "url": "/gists"},
{"name": gist.get("title", "Gist"), "url": f"/gists/{gist['slug'] or gist['uid']}"},
],
schemas=[website_schema(base)],
schemas=[website_schema(base), software_source_code_schema(gist, base)],
)
return templates.TemplateResponse(request, "gist_detail.html", {
**seo_ctx,

View File

@ -5,7 +5,7 @@ from fastapi.responses import HTMLResponse
from devplacepy.database import get_table, db, load_comments, resolve_by_slug
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, time_ago
from devplacepy.seo import base_seo_context, website_schema, site_url, discussion_forum_posting, combine
from devplacepy.seo import base_seo_context, website_schema, site_url, discussion_forum_posting, combine, news_article_schema
logger = logging.getLogger(__name__)
router = APIRouter()
@ -84,8 +84,10 @@ async def news_detail_page(request: Request, news_slug: str):
request,
title=article.get("title", "News Article"),
description=(article.get("description", "") or "")[:200],
og_type="article",
og_image=image_url or None,
breadcrumbs=[{"name": "Home", "url": "/feed"}, {"name": "News", "url": "/news"}, {"name": article.get("title", "")[:60], "url": page_url}],
schemas=[website_schema(base)],
schemas=[website_schema(base), news_article_schema(article, base, image_url)],
)
return templates.TemplateResponse(request, "news_detail.html", {

View File

@ -68,6 +68,7 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
title=f"{profile_user['username']} (@{profile_user['username']})",
description=desc,
robots=robots,
og_type="profile",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": profile_user['username'], "url": f"/profile/{profile_user['username']}"},

View File

@ -1,6 +1,6 @@
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, PlainTextResponse
from fastapi.responses import PlainTextResponse, Response
from devplacepy.seo import make_sitemap, site_url
logger = logging.getLogger(__name__)
@ -17,16 +17,18 @@ Disallow: /notifications/
Disallow: /votes/
Disallow: /avatar/
Disallow: /follow/
Disallow: /admin/
Disallow: /uploads/
Disallow: /*?tab=
Disallow: /*?sort=
Allow: /static/
Sitemap: {base}/sitemap.xml
""")
""", headers={"Cache-Control": "public, max-age=3600"})
@router.get("/sitemap.xml", response_class=HTMLResponse)
@router.get("/sitemap.xml")
async def sitemap_xml(request: Request):
base = site_url(request)
xml = make_sitemap(base)
return HTMLResponse(content=xml, media_type="application/xml")
return Response(content=xml, media_type="application/xml", headers={"Cache-Control": "public, max-age=3600"})

View File

@ -3,6 +3,8 @@ import logging
from datetime import datetime
from xml.etree.ElementTree import Element, tostring
from xml.dom import minidom
from devplacepy.config import SITE_URL
from devplacepy.utils import strip_html
logger = logging.getLogger(__name__)
@ -13,14 +15,15 @@ SITE_NAME = "DevPlace"
def truncate(text, max_len=160):
if not text:
return ""
text = " ".join(text.split())[:max_len]
if len(text) >= max_len:
text = text.rsplit(" ", 1)[0] + "..."
return text
text = " ".join(text.split())
if len(text) <= max_len:
return text
text = text[:max_len - 3].rsplit(" ", 1)[0]
return text + "..."
def site_url(request):
return str(request.base_url).rstrip("/")
return SITE_URL or str(request.base_url).rstrip("/")
def website_schema(base_url):
@ -73,8 +76,6 @@ def discussion_forum_posting(post, author, comment_count, star_count, base_url):
{"@type": "InteractionCounter", "interactionType": "https://schema.org/CommentAction", "userInteractionCount": comment_count}
]
}
if post.get("title"):
schema["headline"] = post["title"]
return schema
@ -98,7 +99,7 @@ def software_application_schema(project, base_url):
"@type": "SoftwareApplication",
"name": project.get("title", "Untitled"),
"description": truncate(project.get("description", ""), 300),
"url": f"{base_url}/projects",
"url": f"{base_url}/projects/{project.get('slug') or project['uid']}",
"applicationCategory": "DeveloperApplication",
"operatingSystem": project.get("platforms", "Cross-platform"),
"author": {
@ -114,6 +115,43 @@ def software_application_schema(project, base_url):
}
def organization_schema(base_url):
return {
"@type": "Organization",
"name": SITE_NAME,
"url": base_url,
"logo": f"{base_url}{DEFAULT_OG_IMAGE}",
}
def news_article_schema(article, base_url, image_url=""):
url = f"{base_url}/news/{article.get('slug') or article['uid']}"
schema = {
"@type": "NewsArticle",
"headline": (article.get("title") or "Untitled")[:110],
"description": truncate(strip_html(article.get("description", "") or ""), 200),
"url": url,
"datePublished": article.get("synced_at", "") or article.get("created_at", ""),
"mainEntityOfPage": {"@type": "WebPage", "@id": url},
"author": {"@type": "Organization", "name": article.get("source_name") or SITE_NAME},
"publisher": organization_schema(base_url),
}
if image_url:
schema["image"] = image_url
return schema
def software_source_code_schema(gist, base_url):
return {
"@type": "SoftwareSourceCode",
"name": gist.get("title") or "Gist",
"description": truncate(strip_html(gist.get("description", "") or ""), 200),
"url": f"{base_url}/gists/{gist.get('slug') or gist['uid']}",
"programmingLanguage": gist.get("language", "") or "text",
"dateCreated": gist.get("created_at", ""),
}
def combine(schemas):
if not schemas:
return None
@ -129,21 +167,25 @@ def combine(schemas):
return json.dumps({"@context": "https://schema.org", "@graph": cleaned}, ensure_ascii=False)
DEFAULT_OG_IMAGE = "/static/og-default.svg"
DEFAULT_OG_IMAGE = "/static/og-default.png"
def base_seo_context(request, title="", description="", robots="index,follow", og_type="website", og_image=None, breadcrumbs=None, schemas=None):
base = site_url(request)
page_title = f"{title} - {SITE_NAME}" if title else SITE_NAME
canonical = str(request.url).split("?")[0]
canonical = f"{base}{request.url.path}"
page = request.query_params.get("page")
if page and page not in ("", "1"):
canonical = f"{canonical}?page={page}"
clean_description = truncate(strip_html(description), 160)
og_img = og_image or f"{base}{DEFAULT_OG_IMAGE}"
return {
"page_title": page_title,
"meta_description": description,
"meta_description": clean_description,
"meta_robots": robots,
"canonical_url": canonical,
"og_title": title or SITE_NAME,
"og_description": description,
"og_description": clean_description,
"og_image": og_img,
"og_type": og_type,
"breadcrumbs": breadcrumbs or [],
@ -180,6 +222,7 @@ def make_sitemap(base_url):
urlset.append(url_element(f"{base_url}/feed", changefreq="hourly", priority="0.9"))
urlset.append(url_element(f"{base_url}/news", changefreq="hourly", priority="0.9"))
urlset.append(url_element(f"{base_url}/projects", changefreq="daily", priority="0.8"))
urlset.append(url_element(f"{base_url}/gists", changefreq="daily", priority="0.8"))
if "posts" in db.tables:
posts = list(get_table("posts").find(order_by=["-created_at"], _limit=500))
@ -211,11 +254,28 @@ def make_sitemap(base_url):
priority="0.6"
))
if "news" in db.tables:
articles = list(get_table("news").find(status="published", order_by=["-synced_at"], _limit=500))
for a in articles:
urlset.append(url_element(
f"{base_url}/news/{a.get('slug') or a['uid']}",
lastmod=a.get("synced_at", "") or a.get("created_at", ""),
changefreq="weekly",
priority="0.7"
))
if "users" in db.tables:
post_counts = {}
if "posts" in db.tables:
for row in db.query("SELECT user_uid, COUNT(*) AS c FROM posts GROUP BY user_uid"):
post_counts[row["user_uid"]] = row["c"]
users = list(get_table("users").find(order_by=["-created_at"], _limit=200))
for u in users:
if post_counts.get(u["uid"], 0) < 2:
continue
urlset.append(url_element(
f"{base_url}/profile/{u['username']}",
lastmod=u.get("created_at", ""),
changefreq="weekly",
priority="0.4"
))

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -259,6 +259,11 @@
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary);
background: none;
border: none;
padding: 0;
font-family: inherit;
cursor: pointer;
}
.news-detail-back:hover {

View File

@ -1,6 +1,7 @@
export class DomUtils {
constructor() {
this.initClipboardCopy();
this.initShareButtons();
this.initTogglers();
this.initStopPropagation();
this.initCardLinks();
@ -23,6 +24,24 @@ export class DomUtils {
});
}
initShareButtons() {
document.querySelectorAll("[data-share]").forEach((btn) => {
btn.addEventListener("click", async (e) => {
e.preventDefault();
e.stopPropagation();
const url = new URL(btn.dataset.share || window.location.href, window.location.href).href;
try {
await navigator.clipboard.writeText(url);
const original = btn.textContent;
btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = original; }, 1000);
} catch {
// silently fail
}
});
});
}
initTogglers() {
document.querySelectorAll("[data-toggle]").forEach((btn) => {
btn.addEventListener("click", () => {

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#0f0a1a">
<title>{% if page_title %}{{ page_title }}{% else %}DevPlace - The Developer Social Network{% endif %}</title>
<meta name="description" content="{% if meta_description %}{{ meta_description }}{% else %}Track industry shifts. Discover bold releases. Share what you're building in an open, uncensored environment.{% endif %}">
<link rel="canonical" href="{{ canonical_url or request.url }}">
@ -12,16 +13,17 @@
<meta property="og:description" content="{{ og_description or meta_description or 'The Developer Social Network' }}">
<meta property="og:type" content="{{ og_type or 'website' }}">
<meta property="og:url" content="{{ canonical_url or request.url }}">
<meta property="og:image" content="{{ og_image }}">
<meta property="og:image" content="{{ og_image or '/static/og-default.png' }}">
<meta property="og:site_name" content="DevPlace">
<meta property="og:locale" content="en_US">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ og_title or page_title or 'DevPlace' }}">
<meta name="twitter:description" content="{{ og_description or meta_description or 'The Developer Social Network' }}">
<meta name="twitter:image" content="{{ og_image }}">
<meta name="twitter:image" content="{{ og_image or '/static/og-default.png' }}">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>&#x1f4bb;</text></svg>">
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net">
<link rel="preconnect" href="https://cdn.jsdelivr.net">

View File

@ -100,7 +100,7 @@
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn share">
&#x2197;&#xFE0E; Open
</a>
<button class="post-action-btn">&#x1F517; Share</button>
<button type="button" class="post-action-btn" data-share="/posts/{{ item.post['slug'] or item.post['uid'] }}">&#x1F517; Share</button>
</div>
{% if user %}

View File

@ -43,6 +43,7 @@
</div>
<div class="gist-detail-actions">
<button type="button" class="gist-star-btn" data-share="/gists/{{ gist['slug'] or gist['uid'] }}">&#x1F517; Share</button>
{% if user %}
<form method="POST" action="/votes/gist/{{ gist['uid'] }}" style="display:inline;">
<input type="hidden" name="value" value="1">

View File

@ -39,6 +39,7 @@
<a href="{{ article['url'] }}" target="_blank" rel="noopener" class="news-detail-read-link">
Read on {{ article['source_name'] }} &#x2197;
</a>
<button type="button" class="news-detail-back" data-share="/news/{{ article['slug'] or article['uid'] }}">&#x1F517; Share</button>
<a href="/news" class="news-detail-back">&larr; Back to News</a>
</div>
</div>

View File

@ -47,7 +47,7 @@
<button type="submit" class="post-action-btn vote-down"></button>
</form>
</div>
<button class="post-action-btn">&#x1F517; Share</button>
<button type="button" class="post-action-btn" data-share="/posts/{{ post['slug'] or post['uid'] }}">&#x1F517; Share</button>
{% if user and post['user_uid'] == user['uid'] %}
<button class="post-action-btn" data-modal="edit-post-modal"><span class="icon">&#x270F;&#xFE0F;</span>Edit</button>
<form method="POST" action="/posts/delete/{{ post['slug'] or post['uid'] }}" class="inline-form">

View File

@ -132,6 +132,7 @@
{% endif %}
<div class="project-detail-actions">
<button type="button" class="project-star-btn" data-share="/projects/{{ project['slug'] or project['uid'] }}">&#x1F517; Share</button>
{% if user %}
<form method="POST" action="/votes/project/{{ project['uid'] }}" style="display:inline;">
<input type="hidden" name="value" value="1">

View File

@ -21,6 +21,9 @@ dependencies = [
[project.scripts]
devplace = "devplacepy.cli:main"
[project.optional-dependencies]
dev = ["pytest", "playwright", "pytest-xdist", "requests"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

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()

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):

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())}"

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):

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):

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):

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):

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
tests/test_ratelimit.py Normal file
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

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
tests/test_seo_unit.py Normal file
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"

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
tests/test_uploads.py Normal file
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