This commit is contained in:
parent
22e066a202
commit
b23f655389
@ -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
6
.gitignore
vendored
@ -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
|
||||
|
||||
@ -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", "*"]
|
||||
|
||||
2
Makefile
2
Makefile
@ -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
|
||||
|
||||
@ -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("/")
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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", {
|
||||
|
||||
@ -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']}"},
|
||||
|
||||
@ -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"})
|
||||
|
||||
@ -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"
|
||||
))
|
||||
|
||||
BIN
devplacepy/static/apple-touch-icon.png
Normal file
BIN
devplacepy/static/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@ -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 {
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
BIN
devplacepy/static/og-default.png
Normal file
BIN
devplacepy/static/og-default.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@ -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>💻</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">
|
||||
|
||||
@ -100,7 +100,7 @@
|
||||
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn share">
|
||||
↗︎ Open
|
||||
</a>
|
||||
<button class="post-action-btn">🔗 Share</button>
|
||||
<button type="button" class="post-action-btn" data-share="/posts/{{ item.post['slug'] or item.post['uid'] }}">🔗 Share</button>
|
||||
</div>
|
||||
|
||||
{% if user %}
|
||||
|
||||
@ -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'] }}">🔗 Share</button>
|
||||
{% if user %}
|
||||
<form method="POST" action="/votes/gist/{{ gist['uid'] }}" style="display:inline;">
|
||||
<input type="hidden" name="value" value="1">
|
||||
|
||||
@ -39,6 +39,7 @@
|
||||
<a href="{{ article['url'] }}" target="_blank" rel="noopener" class="news-detail-read-link">
|
||||
Read on {{ article['source_name'] }} ↗
|
||||
</a>
|
||||
<button type="button" class="news-detail-back" data-share="/news/{{ article['slug'] or article['uid'] }}">🔗 Share</button>
|
||||
<a href="/news" class="news-detail-back">← Back to News</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -47,7 +47,7 @@
|
||||
<button type="submit" class="post-action-btn vote-down">−</button>
|
||||
</form>
|
||||
</div>
|
||||
<button class="post-action-btn">🔗 Share</button>
|
||||
<button type="button" class="post-action-btn" data-share="/posts/{{ post['slug'] or post['uid'] }}">🔗 Share</button>
|
||||
{% if user and post['user_uid'] == user['uid'] %}
|
||||
<button class="post-action-btn" data-modal="edit-post-modal"><span class="icon">✏️</span>Edit</button>
|
||||
<form method="POST" action="/posts/delete/{{ post['slug'] or post['uid'] }}" class="inline-form">
|
||||
|
||||
@ -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'] }}">🔗 Share</button>
|
||||
{% if user %}
|
||||
<form method="POST" action="/votes/project/{{ project['uid'] }}" style="display:inline;">
|
||||
<input type="hidden" name="value" value="1">
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
31
tests/test_ratelimit.py
Normal 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
|
||||
@ -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
49
tests/test_seo_unit.py
Normal 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"
|
||||
@ -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
71
tests/test_uploads.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user