chore: reorganize test files into domain-specific subdirectories

This commit is contained in:
2026-06-13 14:32:33 +00:00
parent 8eac4cafba
commit eccec02336
268 changed files with 20634 additions and 9206 deletions
View File
View File
View File
+74
View File
@@ -0,0 +1,74 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timezone
from types import SimpleNamespace
from tests.conftest import BASE_URL
from devplacepy.database import get_table, set_setting
from devplacepy.utils import generate_uid
from devplacepy.services.devii.config import (
effective_daily_limit,
FIELD_USER_DAILY_USD,
FIELD_GUEST_DAILY_USD,
FIELD_ADMIN_DAILY_USD,
)
from devplacepy.services.devii.store import UsageLedger
LEDGER = "devii_usage_ledger"
def _now_iso_devii_quota():
return datetime.now(timezone.utc).isoformat()
def _seed_ledger(owner_kind, owner_id, cost, n=1):
table = get_table(LEDGER)
for _ in range(n):
table.insert(
{
"owner_kind": owner_kind,
"owner_id": owner_id,
"created_at": _now_iso_devii_quota(),
"cost_usd": cost,
}
)
def _ensure_devii():
from devplacepy.services.manager import service_manager
from devplacepy.services.devii import DeviiService
if service_manager.get_service("devii") is None:
service_manager.register(DeviiService())
return service_manager.get_service("devii")
def _make_user_devii_quota(role="Member"):
uid = generate_uid()
username = f"quota_{uid[-12:]}"
get_table("users").insert(
{
"uid": uid,
"username": username,
"email": f"{username}@t.dev",
"api_key": generate_uid(),
"role": role,
"is_active": True,
}
)
return uid, username
def test_admin_reset_all_endpoint(alice):
page, _ = alice
_seed_ledger("guest", f"guest-{generate_uid()[:8]}", 0.01)
_seed_ledger("user", _make_user_devii_quota()[0], 0.5)
resp = page.request.post(f"{BASE_URL}/admin/ai-quota/reset-all")
assert resp.ok
assert get_table(LEDGER).count() == 0
def test_non_admin_cannot_reset_quota(bob):
page, _ = bob
marker = f"guest-{generate_uid()[:8]}"
get_table(LEDGER).insert(
{
"owner_kind": "guest",
"owner_id": marker,
"created_at": _now_iso_devii_quota(),
"cost_usd": 0.01,
}
)
page.request.post(f"{BASE_URL}/admin/ai-quota/reset-all")
assert get_table(LEDGER).count(owner_kind="guest", owner_id=marker) == 1
get_table(LEDGER).delete(owner_kind="guest", owner_id=marker)
View File
+73
View File
@@ -0,0 +1,73 @@
# retoor <retoor@molodetz.nl>
from uuid import uuid4
from datetime import datetime, timezone
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
def seed_admin_news(count=3):
news_table = get_table("news")
news_table.delete()
for i in range(count):
eid = f"admin_test_news_{i}"
if news_table.find_one(external_id=eid):
continue
uid = str(uuid4())
title = f"Admin News Article {i}"
slug = make_combined_slug(title, uid)
news_table.insert(
{
"uid": uid,
"slug": slug,
"title": title,
"external_id": eid,
"grade": 5 + i,
"status": "draft" if i == 0 else "published",
"show_on_landing": 1 if i == 1 else 0,
"source_name": "AdminTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": f"Test article {i} for admin tests.",
}
)
def seed_extra_users(count=30):
users = get_table("users")
existing_count = len(list(users.all()))
if existing_count > 5:
return
for i in range(count):
uid = str(uuid4())
users.insert(
{
"uid": uid,
"username": f"pagu_{i:04d}",
"email": f"pagu{i:04d}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
def _seed_target_user():
uid = str(uuid4())
get_table("users").insert(
{
"uid": uid,
"username": f"target_{uid[:8]}",
"email": f"target_{uid[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def test_admin_ai_usage_data(alice):
page, _ = alice
resp = page.request.get(f"{BASE_URL}/admin/ai-usage/data?hours=48")
assert resp.status == 200
data = resp.json()
assert "hourly" in data
assert "cost" in data
assert "requests" in data
+69
View File
@@ -0,0 +1,69 @@
# retoor <retoor@molodetz.nl>
from uuid import uuid4
from datetime import datetime, timezone
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
def seed_admin_news(count=3):
news_table = get_table("news")
news_table.delete()
for i in range(count):
eid = f"admin_test_news_{i}"
if news_table.find_one(external_id=eid):
continue
uid = str(uuid4())
title = f"Admin News Article {i}"
slug = make_combined_slug(title, uid)
news_table.insert(
{
"uid": uid,
"slug": slug,
"title": title,
"external_id": eid,
"grade": 5 + i,
"status": "draft" if i == 0 else "published",
"show_on_landing": 1 if i == 1 else 0,
"source_name": "AdminTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": f"Test article {i} for admin tests.",
}
)
def seed_extra_users(count=30):
users = get_table("users")
existing_count = len(list(users.all()))
if existing_count > 5:
return
for i in range(count):
uid = str(uuid4())
users.insert(
{
"uid": uid,
"username": f"pagu_{i:04d}",
"email": f"pagu{i:04d}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
def _seed_target_user():
uid = str(uuid4())
get_table("users").insert(
{
"uid": uid,
"username": f"target_{uid[:8]}",
"email": f"target_{uid[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def test_admin_ai_usage_page_loads(alice):
page, _ = alice
page.goto(f"{BASE_URL}/admin/ai-usage", wait_until="domcontentloaded")
assert page.is_visible("text=AI usage") or page.is_visible("text=AI Usage")
+73
View File
@@ -0,0 +1,73 @@
# retoor <retoor@molodetz.nl>
from uuid import uuid4
from datetime import datetime, timezone
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
def seed_admin_news(count=3):
news_table = get_table("news")
news_table.delete()
for i in range(count):
eid = f"admin_test_news_{i}"
if news_table.find_one(external_id=eid):
continue
uid = str(uuid4())
title = f"Admin News Article {i}"
slug = make_combined_slug(title, uid)
news_table.insert(
{
"uid": uid,
"slug": slug,
"title": title,
"external_id": eid,
"grade": 5 + i,
"status": "draft" if i == 0 else "published",
"show_on_landing": 1 if i == 1 else 0,
"source_name": "AdminTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": f"Test article {i} for admin tests.",
}
)
def seed_extra_users(count=30):
users = get_table("users")
existing_count = len(list(users.all()))
if existing_count > 5:
return
for i in range(count):
uid = str(uuid4())
users.insert(
{
"uid": uid,
"username": f"pagu_{i:04d}",
"email": f"pagu{i:04d}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
def _seed_target_user():
uid = str(uuid4())
get_table("users").insert(
{
"uid": uid,
"username": f"target_{uid[:8]}",
"email": f"target_{uid[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def test_admin_analytics_page(alice):
page, _ = alice
resp = page.request.get(f"{BASE_URL}/admin/analytics")
assert resp.status == 200
data = resp.json()
assert "total_members" in data
assert "totals" in data
assert "posts" in data["totals"]
+274
View File
@@ -0,0 +1,274 @@
# retoor <retoor@molodetz.nl>
from uuid import uuid4
from datetime import datetime, timezone
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
def seed_admin_news(count=3):
news_table = get_table("news")
news_table.delete()
for i in range(count):
eid = f"admin_test_news_{i}"
if news_table.find_one(external_id=eid):
continue
uid = str(uuid4())
title = f"Admin News Article {i}"
slug = make_combined_slug(title, uid)
news_table.insert(
{
"uid": uid,
"slug": slug,
"title": title,
"external_id": eid,
"grade": 5 + i,
"status": "draft" if i == 0 else "published",
"show_on_landing": 1 if i == 1 else 0,
"source_name": "AdminTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": f"Test article {i} for admin tests.",
}
)
def seed_extra_users(count=30):
users = get_table("users")
existing_count = len(list(users.all()))
if existing_count > 5:
return
for i in range(count):
uid = str(uuid4())
users.insert(
{
"uid": uid,
"username": f"pagu_{i:04d}",
"email": f"pagu{i:04d}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
def _seed_target_user():
uid = str(uuid4())
get_table("users").insert(
{
"uid": uid,
"username": f"target_{uid[:8]}",
"email": f"target_{uid[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
import time
import pytest
import requests
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
JSON_audit_log = {"Accept": "application/json"}
_counter_audit_log = [0]
@pytest.fixture(scope="module", autouse=True)
def _audit_test_settings(app_server):
# On a fresh DB init_db skips seeding the operational/upload settings (its
# `tables` snapshot predates site_settings creation), so those rows are
# absent and the admin settings form would INSERT them as "" - which both
# closes registration and makes consumers that do int("") crash. Seed sane
# values here so the form's empty submissions are skipped (existing key), and
# lift the per-IP rate limit since this file fires many mutating requests.
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(name):
# the user is created by the server subprocess; refresh the test-process
# SQLite snapshot before reading it back across the process boundary.
refresh_snapshot()
return get_table("users").find_one(username=name)
def _unique(prefix="au"):
_counter_audit_log[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
def _member():
name = _unique("aumem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _member_key():
_, name = _member()
return _db_user(name)["api_key"]
def _admin(seeded_db):
# authenticate via the seeded admin's API key (header auth) rather than a
# login POST - GET reads are exempt from the rate limiter, so reusing this
# across the file's many tests never counts against the per-IP write budget.
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def _audit(admin, **params):
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
assert r.status_code == 200, r.text[:300]
return r.json()
def _find(admin, event_key, predicate):
data = _audit(admin, event_key=event_key)
for entry in data["entries"]:
if predicate(entry):
return entry
return None
def _new_post(session, body="audited post body here"):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
).json()["data"]
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupr"),
"description": "audited project description text",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def _seed_news_audit_log():
uid = generate_uid()
title = _unique("aunews")
get_table("news").insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": uid,
"status": "draft",
"featured": 0,
"show_on_landing": 0,
"grade": 5,
"source_name": "AuditTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "audited news article",
}
)
refresh_snapshot()
return uid
def test_admin_audit_log_page_loads(alice):
page, _ = alice
page.goto(f"{BASE_URL}/admin/audit-log", wait_until="domcontentloaded")
assert page.is_visible("text=Audit Log") or page.is_visible("text=Audit")
def test_admin_audit_log_event_listing(alice):
page, _ = alice
page.goto(f"{BASE_URL}/admin/audit-log", wait_until="domcontentloaded")
table = page.locator("table")
assert table.is_visible() or page.is_visible("text=No audit events")
def test_audit_log_page_loads(alice):
page, _ = alice
page.goto(f"{BASE_URL}/admin/audit-log", wait_until="domcontentloaded")
assert page.is_visible("h2:has-text('Audit Log')")
assert page.locator("table.admin-table").count() == 1
active = page.locator(
".sidebar-link.active[href='/admin/audit-log']"
)
assert active.count() == 1
def test_audit_log_filter_bar_renders(alice):
page, _ = alice
page.goto(f"{BASE_URL}/admin/audit-log", wait_until="domcontentloaded")
for name in ("category", "event_key", "actor_role", "origin", "result"):
assert page.locator(f"select[name='{name}']").count() == 1
assert page.locator("input[name='q']").count() == 1
assert page.locator("input[name='date_from']").count() == 1
assert page.locator(".audit-filter-bar button:has-text('Filter')").count() == 1
assert page.locator(".audit-filter-bar a:has-text('Clear')").count() == 1
def test_audit_log_filter_apply_search(alice):
page, _ = alice
page.goto(f"{BASE_URL}/admin/audit-log", wait_until="domcontentloaded")
page.fill("input[name='q']", "auth")
page.click(".audit-filter-bar button:has-text('Filter')")
page.wait_for_url("**/admin/audit-log?**q=auth**", wait_until="domcontentloaded")
assert "q=auth" in page.url
assert page.locator("input[name='q']").input_value() == "auth"
def test_audit_log_filter_by_category_select(alice):
page, _ = alice
page.goto(f"{BASE_URL}/admin/audit-log", wait_until="domcontentloaded")
page.select_option("select[name='category']", "auth")
page.click(".audit-filter-bar button:has-text('Filter')")
page.wait_for_url(
"**/admin/audit-log?**category=auth**", wait_until="domcontentloaded"
)
rows = page.locator("table.admin-table tbody tr")
assert rows.count() > 0
# every visible event badge in the auth category begins with a known auth prefix
badges = page.locator("table.admin-table tbody tr td .audit-badge").all_inner_texts()
assert any(b.startswith("auth.") for b in badges)
def test_audit_log_clear_filters(alice):
page, _ = alice
page.goto(
f"{BASE_URL}/admin/audit-log?category=auth&q=login",
wait_until="domcontentloaded",
)
page.click(".audit-filter-bar a:has-text('Clear')")
page.wait_for_url(f"{BASE_URL}/admin/audit-log", wait_until="domcontentloaded")
assert page.url.rstrip("/") == f"{BASE_URL}/admin/audit-log"
def test_audit_log_view_detail(alice):
page, _ = alice
page.goto(f"{BASE_URL}/admin/audit-log", wait_until="domcontentloaded")
page.locator("a:has-text('View')").first.click()
page.wait_for_url("**/admin/audit-log/**", wait_until="domcontentloaded")
assert page.is_visible("text=Related objects")
assert page.locator(".audit-detail-grid").count() == 1
assert page.locator(".audit-badge").first.is_visible()
def test_audit_log_detail_unknown_404(alice):
page, _ = alice
resp = page.goto(
f"{BASE_URL}/admin/audit-log/nonexistent", wait_until="domcontentloaded"
)
assert resp.status == 404
def test_audit_log_guest_redirects_to_login(page, app_server):
page.goto(f"{BASE_URL}/admin/audit-log", wait_until="domcontentloaded")
assert page.url.startswith(f"{BASE_URL}/auth/login")
def test_audit_log_member_redirects_to_feed(bob):
page, _ = bob
page.goto(f"{BASE_URL}/admin/audit-log", wait_until="domcontentloaded")
assert page.url.rstrip("/") == f"{BASE_URL}/feed"
+21
View File
@@ -0,0 +1,21 @@
# retoor <retoor@molodetz.nl>
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
def test_containers_admin_data_returns_json(alice, seeded_db):
s = requests.Session()
s.post(
f"{BASE_URL}/auth/login",
data={
"email": seeded_db["alice"]["email"],
"password": seeded_db["alice"]["password"],
},
)
r = s.get(f"{BASE_URL}/admin/containers/data")
assert r.status_code == 200
data = r.json()
assert "instances" in data
assert isinstance(data["instances"], list)
+17
View File
@@ -0,0 +1,17 @@
# retoor <retoor@molodetz.nl>
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
def test_containers_admin_page_loads(alice, app_server):
page, user = alice
page.goto(f"{BASE_URL}/admin/containers", wait_until="domcontentloaded")
assert page.is_visible(".admin-toolbar h2:has-text('Containers')")
def test_container_instance_page_404_for_missing(alice, app_server):
page, _ = alice
page.goto(f"{BASE_URL}/admin/containers/nonexistent", wait_until="domcontentloaded")
assert page.is_visible("text=Not Found") or page.is_visible("text=404")
+68
View File
@@ -0,0 +1,68 @@
# retoor <retoor@molodetz.nl>
from uuid import uuid4
from datetime import datetime, timezone
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
def seed_admin_news(count=3):
news_table = get_table("news")
news_table.delete()
for i in range(count):
eid = f"admin_test_news_{i}"
if news_table.find_one(external_id=eid):
continue
uid = str(uuid4())
title = f"Admin News Article {i}"
slug = make_combined_slug(title, uid)
news_table.insert(
{
"uid": uid,
"slug": slug,
"title": title,
"external_id": eid,
"grade": 5 + i,
"status": "draft" if i == 0 else "published",
"show_on_landing": 1 if i == 1 else 0,
"source_name": "AdminTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": f"Test article {i} for admin tests.",
}
)
def seed_extra_users(count=30):
users = get_table("users")
existing_count = len(list(users.all()))
if existing_count > 5:
return
for i in range(count):
uid = str(uuid4())
users.insert(
{
"uid": uid,
"username": f"pagu_{i:04d}",
"email": f"pagu{i:04d}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
def _seed_target_user():
uid = str(uuid4())
get_table("users").insert(
{
"uid": uid,
"username": f"target_{uid[:8]}",
"email": f"target_{uid[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def test_admin_redirect_unauth(page, app_server):
page.goto(f"{BASE_URL}/admin", wait_until="domcontentloaded")
assert page.url.startswith(f"{BASE_URL}/auth/login")
+69
View File
@@ -0,0 +1,69 @@
# retoor <retoor@molodetz.nl>
from uuid import uuid4
from datetime import datetime, timezone
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
def seed_admin_news(count=3):
news_table = get_table("news")
news_table.delete()
for i in range(count):
eid = f"admin_test_news_{i}"
if news_table.find_one(external_id=eid):
continue
uid = str(uuid4())
title = f"Admin News Article {i}"
slug = make_combined_slug(title, uid)
news_table.insert(
{
"uid": uid,
"slug": slug,
"title": title,
"external_id": eid,
"grade": 5 + i,
"status": "draft" if i == 0 else "published",
"show_on_landing": 1 if i == 1 else 0,
"source_name": "AdminTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": f"Test article {i} for admin tests.",
}
)
def seed_extra_users(count=30):
users = get_table("users")
existing_count = len(list(users.all()))
if existing_count > 5:
return
for i in range(count):
uid = str(uuid4())
users.insert(
{
"uid": uid,
"username": f"pagu_{i:04d}",
"email": f"pagu{i:04d}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
def _seed_target_user():
uid = str(uuid4())
get_table("users").insert(
{
"uid": uid,
"username": f"target_{uid[:8]}",
"email": f"target_{uid[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def test_admin_media_page_loads(alice):
page, _ = alice
page.goto(f"{BASE_URL}/admin/media", wait_until="domcontentloaded")
assert page.is_visible("text=Media") or page.is_visible("text=Deleted Media")
+145
View File
@@ -0,0 +1,145 @@
# retoor <retoor@molodetz.nl>
from uuid import uuid4
from datetime import datetime, timezone
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
def seed_admin_news(count=3):
news_table = get_table("news")
news_table.delete()
for i in range(count):
eid = f"admin_test_news_{i}"
if news_table.find_one(external_id=eid):
continue
uid = str(uuid4())
title = f"Admin News Article {i}"
slug = make_combined_slug(title, uid)
news_table.insert(
{
"uid": uid,
"slug": slug,
"title": title,
"external_id": eid,
"grade": 5 + i,
"status": "draft" if i == 0 else "published",
"show_on_landing": 1 if i == 1 else 0,
"source_name": "AdminTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": f"Test article {i} for admin tests.",
}
)
def seed_extra_users(count=30):
users = get_table("users")
existing_count = len(list(users.all()))
if existing_count > 5:
return
for i in range(count):
uid = str(uuid4())
users.insert(
{
"uid": uid,
"username": f"pagu_{i:04d}",
"email": f"pagu{i:04d}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
def _seed_target_user():
uid = str(uuid4())
get_table("users").insert(
{
"uid": uid,
"username": f"target_{uid[:8]}",
"email": f"target_{uid[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def test_admin_news_loads(alice):
page, _ = alice
seed_admin_news()
page.goto(f"{BASE_URL}/admin/news", wait_until="domcontentloaded")
assert page.is_visible("text=News")
assert page.is_visible("text=Admin News Article")
def test_admin_news_publish_toggle(alice):
page, _ = alice
seed_admin_news()
page.goto(f"{BASE_URL}/admin/news", wait_until="domcontentloaded")
toggle_sel = "form[action*='/publish'] button.admin-toggle-switch"
was_active = "active" in (
page.locator(toggle_sel).first.get_attribute("class") or ""
)
page.locator(toggle_sel).first.click()
page.wait_for_url(f"{BASE_URL}/admin/news", wait_until="domcontentloaded")
is_active = "active" in (
page.locator(toggle_sel).first.get_attribute("class") or ""
)
assert is_active != was_active
def test_admin_news_landing_toggle(alice):
page, _ = alice
seed_admin_news()
page.goto(f"{BASE_URL}/admin/news", wait_until="domcontentloaded")
toggle_sel = "form[action*='/landing'] button.admin-toggle-switch"
was_active = "active" in (
page.locator(toggle_sel).first.get_attribute("class") or ""
)
page.locator(toggle_sel).first.click()
page.wait_for_url(f"{BASE_URL}/admin/news", wait_until="domcontentloaded")
is_active = "active" in (
page.locator(toggle_sel).first.get_attribute("class") or ""
)
assert is_active != was_active
def test_admin_news_delete(alice):
page, _ = alice
seed_admin_news()
page.goto(f"{BASE_URL}/admin/news", wait_until="domcontentloaded")
initial_count = len(page.locator("table tbody tr").all())
page.locator("form[action*='/delete'] button").first.click()
page.locator(".dialog-overlay.visible .dialog-confirm").click()
page.wait_for_url(f"{BASE_URL}/admin/news", wait_until="domcontentloaded")
page.wait_for_timeout(1000)
new_count = len(page.locator("table tbody tr").all())
assert new_count < initial_count
def test_admin_news_pagination(alice):
page, _ = alice
seed_admin_news(30)
page.goto(f"{BASE_URL}/admin/news", wait_until="domcontentloaded")
assert page.text_content(".pagination-info") is not None
assert "Page 1" in page.text_content(".pagination-info")
next_btn = page.locator("a.pagination-btn:has-text('Next')")
assert next_btn.count() > 0, "30 seeded articles must span more than one page"
next_btn.first.click()
page.wait_for_url(f"{BASE_URL}/admin/news?page=2", wait_until="domcontentloaded")
assert "Page 2" in page.text_content(".pagination-info")
def test_admin_news_featured_toggle(alice):
page, _ = alice
seed_admin_news()
page.goto(f"{BASE_URL}/admin/news", wait_until="domcontentloaded")
toggle_sel = "form[action$='/toggle'] button.admin-toggle-switch"
was_active = "active" in (
page.locator(toggle_sel).first.get_attribute("class") or ""
)
page.locator(toggle_sel).first.click()
page.wait_for_url(f"{BASE_URL}/admin/news", wait_until="domcontentloaded")
is_active = "active" in (
page.locator(toggle_sel).first.get_attribute("class") or ""
)
assert is_active != was_active
+48
View File
@@ -0,0 +1,48 @@
# retoor <retoor@molodetz.nl>
import requests
from playwright.sync_api import expect
from tests.conftest import BASE_URL
from devplacepy.database import (
get_notification_default,
get_notification_prefs,
get_table,
notification_enabled,
reset_notification_prefs,
set_notification_default,
set_notification_pref,
)
from devplacepy.utils import create_notification, generate_uid
def _user_notification_prefs(username):
return get_table("users").find_one(username=username)
def _pref(prefs, key):
return next(p for p in prefs if p["key"] == key)
def test_admin_default_endpoint(alice):
_, admin_user = alice
admin = _user_notification_prefs(admin_user["username"])
try:
response = requests.post(
f"{BASE_URL}/admin/notifications",
data={"notification_type": "level", "channel": "push", "value": "false"},
headers={"Accept": "application/json", "X-API-KEY": admin["api_key"]},
allow_redirects=False,
)
assert response.status_code == 200
assert get_notification_default("level", "push") is False
finally:
set_notification_default("level", "push", True)
def test_member_cannot_set_default(bob):
_, user = bob
member = _user_notification_prefs(user["username"])
response = requests.post(
f"{BASE_URL}/admin/notifications",
data={"notification_type": "level", "channel": "push", "value": "false"},
headers={"Accept": "application/json", "X-API-KEY": member["api_key"]},
allow_redirects=False,
)
assert response.status_code in (302, 303, 403)
assert get_notification_default("level", "push") is True
+20
View File
@@ -0,0 +1,20 @@
# retoor <retoor@molodetz.nl>
from tests.conftest import BASE_URL, login_user
from devplacepy.database import get_table
def _promote_to_admin(username: str) -> None:
users = get_table("users")
user = users.find_one(username=username)
if user:
users.update({"uid": user["uid"], "role": "Admin"}, ["uid"])
def test_services_clear_logs(page, seeded_db):
user = seeded_db["alice"]
_promote_to_admin(user["username"])
login_user(page, user)
page.goto(f"{BASE_URL}/admin/services", wait_until="domcontentloaded")
resp = page.request.post(f"{BASE_URL}/admin/services/news/clear-logs")
assert resp.status == 200
data = resp.json()
assert data["ok"] is True
+56
View File
@@ -0,0 +1,56 @@
# retoor <retoor@molodetz.nl>
from tests.conftest import BASE_URL, login_user
from devplacepy.database import get_table
def _promote_to_admin(username: str) -> None:
users = get_table("users")
user = users.find_one(username=username)
if user:
users.update({"uid": user["uid"], "role": "Admin"}, ["uid"])
def test_services_config_validation(page, seeded_db):
user = seeded_db["alice"]
_promote_to_admin(user["username"])
login_user(page, user)
page.goto(f"{BASE_URL}/admin/services", wait_until="domcontentloaded")
bad = page.request.post(
f"{BASE_URL}/admin/services/news/config", form={"news_grade_threshold": "99"}
)
assert bad.status == 400
body = bad.json()
assert body["ok"] is False
assert "news_grade_threshold" in body["errors"]
try:
good = page.request.post(
f"{BASE_URL}/admin/services/news/config", form={"news_grade_threshold": "8"}
)
assert good.ok
assert good.json()["ok"] is True
finally:
page.request.post(
f"{BASE_URL}/admin/services/news/config", form={"news_grade_threshold": "7"}
)
def test_bots_config_float_validation(page, seeded_db):
user = seeded_db["alice"]
_promote_to_admin(user["username"])
login_user(page, user)
page.goto(f"{BASE_URL}/admin/services", wait_until="domcontentloaded")
bad = page.request.post(
f"{BASE_URL}/admin/services/bots/config", form={"bot_input_cost_per_1m": "abc"}
)
assert bad.status == 400
assert "bot_input_cost_per_1m" in bad.json()["errors"]
try:
good = page.request.post(
f"{BASE_URL}/admin/services/bots/config",
form={"bot_input_cost_per_1m": "0.42"},
)
assert good.ok and good.json()["ok"] is True
finally:
page.request.post(
f"{BASE_URL}/admin/services/bots/config",
form={"bot_input_cost_per_1m": "0.27"},
)
+47
View File
@@ -0,0 +1,47 @@
# retoor <retoor@molodetz.nl>
from tests.conftest import BASE_URL, login_user
from devplacepy.database import get_table
def _promote_to_admin(username: str) -> None:
users = get_table("users")
user = users.find_one(username=username)
if user:
users.update({"uid": user["uid"], "role": "Admin"}, ["uid"])
def test_services_data_json(page, seeded_db):
user = seeded_db["alice"]
_promote_to_admin(user["username"])
login_user(page, user)
page.goto(f"{BASE_URL}/admin/services/data", wait_until="domcontentloaded")
text = page.inner_text("body > pre") if page.locator("body > pre").count() > 0 else page.content()
import json
data = json.loads(text)
assert "services" in data
assert isinstance(data["services"], list)
def test_service_detail_unknown_404(page, seeded_db):
user = seeded_db["alice"]
_promote_to_admin(user["username"])
login_user(page, user)
resp = page.request.get(f"{BASE_URL}/admin/services/nope")
assert resp.status == 404
assert page.request.get(f"{BASE_URL}/admin/services/nope/data").status == 404
def test_bots_service_registered_and_opt_in(page, seeded_db):
user = seeded_db["alice"]
_promote_to_admin(user["username"])
login_user(page, user)
page.goto(f"{BASE_URL}/admin/services", wait_until="domcontentloaded")
data = page.request.get(f"{BASE_URL}/admin/services/data").json()
bots = next(s for s in data["services"] if s["name"] == "bots")
assert bots["enabled"] is False
assert bots["status"] == "stopped"
assert "metrics" in bots
page.goto(f"{BASE_URL}/admin/services/bots", wait_until="domcontentloaded")
detail = page.locator("[data-service-detail][data-service='bots']")
assert detail.locator("input[name='bot_fleet_size']").count() == 1
assert detail.locator("input[name='bot_input_cost_per_1m']").count() == 1
+62
View File
@@ -0,0 +1,62 @@
# retoor <retoor@molodetz.nl>
from tests.conftest import BASE_URL, login_user
from devplacepy.database import get_table
def _promote_to_admin(username: str) -> None:
users = get_table("users")
user = users.find_one(username=username)
if user:
users.update({"uid": user["uid"], "role": "Admin"}, ["uid"])
def test_services_page_non_admin_redirect(bob):
page, _ = bob
page.goto(f"{BASE_URL}/admin/services", wait_until="domcontentloaded")
assert page.url.rstrip("/").endswith("/feed")
assert "/admin/services" not in page.url
def test_services_page_admin_loads(page, seeded_db):
user = seeded_db["alice"]
_promote_to_admin(user["username"])
login_user(page, user)
page.goto(f"{BASE_URL}/admin/services", wait_until="domcontentloaded")
assert page.is_visible("text=Services")
def test_services_sidebar_link(page, seeded_db):
user = seeded_db["alice"]
_promote_to_admin(user["username"])
login_user(page, user)
page.goto(f"{BASE_URL}/admin/services", wait_until="domcontentloaded")
link = page.locator("a.sidebar-link[href='/admin/services']")
assert link.is_visible()
assert "active" in (link.get_attribute("class") or "")
def test_services_index_lists_with_links(page, seeded_db):
user = seeded_db["alice"]
_promote_to_admin(user["username"])
login_user(page, user)
page.goto(f"{BASE_URL}/admin/services", wait_until="domcontentloaded")
row = page.locator(".service-row[data-service='news']")
assert row.count() == 1
assert row.locator("a[href='/admin/services/news']").count() >= 1
assert page.locator(".service-row-desc").count() >= 1
# index is a slim list: no config forms or per-service controls
assert page.locator("[data-config-form]").count() == 0
def test_service_detail_has_tabs_and_config(page, seeded_db):
user = seeded_db["alice"]
_promote_to_admin(user["username"])
login_user(page, user)
page.goto(f"{BASE_URL}/admin/services/news", wait_until="domcontentloaded")
detail = page.locator("[data-service-detail][data-service='news']")
assert detail.count() == 1
assert detail.locator("[data-tabs] [data-tab='config']").count() == 1
assert detail.locator("[data-config-form]").count() == 1
assert detail.locator("input[name='news_grade_threshold']").count() == 1
for action in ("start", "stop", "run", "clear-logs"):
assert detail.locator(f"[data-action='{action}']").count() == 1
assert detail.locator("fieldset.config-section").count() >= 1
+20
View File
@@ -0,0 +1,20 @@
# retoor <retoor@molodetz.nl>
from tests.conftest import BASE_URL, login_user
from devplacepy.database import get_table
def _promote_to_admin(username: str) -> None:
users = get_table("users")
user = users.find_one(username=username)
if user:
users.update({"uid": user["uid"], "role": "Admin"}, ["uid"])
def test_services_run_now(page, seeded_db):
user = seeded_db["alice"]
_promote_to_admin(user["username"])
login_user(page, user)
page.goto(f"{BASE_URL}/admin/services", wait_until="domcontentloaded")
resp = page.request.post(f"{BASE_URL}/admin/services/news/run")
assert resp.status == 200
data = resp.json()
assert data["ok"] is True
+28
View File
@@ -0,0 +1,28 @@
# retoor <retoor@molodetz.nl>
from tests.conftest import BASE_URL, login_user
from devplacepy.database import get_table
def _promote_to_admin(username: str) -> None:
users = get_table("users")
user = users.find_one(username=username)
if user:
users.update({"uid": user["uid"], "role": "Admin"}, ["uid"])
def test_services_start_stop_toggles_enabled(page, seeded_db):
user = seeded_db["alice"]
_promote_to_admin(user["username"])
login_user(page, user)
page.goto(f"{BASE_URL}/admin/services", wait_until="domcontentloaded")
try:
assert page.request.post(f"{BASE_URL}/admin/services/news/stop").ok
data = page.request.get(f"{BASE_URL}/admin/services/data").json()
news = next(s for s in data["services"] if s["name"] == "news")
assert news["enabled"] is False
assert news["status"] == "stopped"
assert page.request.post(f"{BASE_URL}/admin/services/news/start").ok
data = page.request.get(f"{BASE_URL}/admin/services/data").json()
news = next(s for s in data["services"] if s["name"] == "news")
assert news["enabled"] is True
finally:
page.request.post(f"{BASE_URL}/admin/services/news/start")
+17
View File
@@ -0,0 +1,17 @@
# retoor <retoor@molodetz.nl>
from tests.conftest import BASE_URL, login_user
from devplacepy.database import get_table
def _promote_to_admin(username: str) -> None:
users = get_table("users")
user = users.find_one(username=username)
if user:
users.update({"uid": user["uid"], "role": "Admin"}, ["uid"])
def test_services_unknown_returns_404(page, seeded_db):
user = seeded_db["alice"]
_promote_to_admin(user["username"])
login_user(page, user)
page.goto(f"{BASE_URL}/admin/services", wait_until="domcontentloaded")
assert page.request.post(f"{BASE_URL}/admin/services/nope/stop").status == 404
+125
View File
@@ -0,0 +1,125 @@
# retoor <retoor@molodetz.nl>
from uuid import uuid4
from datetime import datetime, timezone
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
def seed_admin_news(count=3):
news_table = get_table("news")
news_table.delete()
for i in range(count):
eid = f"admin_test_news_{i}"
if news_table.find_one(external_id=eid):
continue
uid = str(uuid4())
title = f"Admin News Article {i}"
slug = make_combined_slug(title, uid)
news_table.insert(
{
"uid": uid,
"slug": slug,
"title": title,
"external_id": eid,
"grade": 5 + i,
"status": "draft" if i == 0 else "published",
"show_on_landing": 1 if i == 1 else 0,
"source_name": "AdminTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": f"Test article {i} for admin tests.",
}
)
def seed_extra_users(count=30):
users = get_table("users")
existing_count = len(list(users.all()))
if existing_count > 5:
return
for i in range(count):
uid = str(uuid4())
users.insert(
{
"uid": uid,
"username": f"pagu_{i:04d}",
"email": f"pagu{i:04d}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
def _seed_target_user():
uid = str(uuid4())
get_table("users").insert(
{
"uid": uid,
"username": f"target_{uid[:8]}",
"email": f"target_{uid[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
import time
import requests
DEFAULT_MAINTENANCE_MESSAGE = (
"DevPlace is undergoing scheduled maintenance. Please check back shortly."
)
OPERATIONAL_FIELDS = (
"rate_limit_per_minute",
"rate_limit_window_seconds",
"session_max_age_days",
"session_remember_days",
"registration_open",
"maintenance_mode",
"maintenance_message",
)
def _save_settings(page, **fields):
page.goto(f"{BASE_URL}/admin/settings", wait_until="domcontentloaded")
for name, value in fields.items():
locator = page.locator(f"#{name}")
tag = locator.evaluate("el => el.tagName.toLowerCase()")
if tag == "select":
page.select_option(f"#{name}", value)
else:
locator.fill(value)
page.click("button:has-text('Save Settings')")
page.wait_for_url("**/admin/settings", wait_until="domcontentloaded")
def test_admin_settings_save(alice):
page, _ = alice
page.goto(f"{BASE_URL}/admin/settings", wait_until="domcontentloaded")
assert page.is_visible("#site_name")
original = page.locator("#site_tagline").input_value()
newval = f"tagline-{uuid4().hex[:8]}"
try:
page.fill("#site_tagline", newval)
page.click("button:has-text('Save Settings')")
page.wait_for_url("**/admin/settings", wait_until="domcontentloaded")
assert page.locator("#site_tagline").input_value() == newval
finally:
page.fill("#site_tagline", original)
page.click("button:has-text('Save Settings')")
page.wait_for_url("**/admin/settings", wait_until="domcontentloaded")
def test_operational_fields_render(alice):
page, _ = alice
page.goto(f"{BASE_URL}/admin/settings", wait_until="domcontentloaded")
assert page.is_visible("text=Operational")
for field in OPERATIONAL_FIELDS:
assert page.is_visible(f"#{field}"), field
def test_maintenance_mode_admin_retains_access(alice):
page, _ = alice
try:
_save_settings(page, maintenance_mode="1")
page.goto(f"{BASE_URL}/admin/settings", wait_until="domcontentloaded")
assert page.is_visible("#maintenance_mode")
login = requests.get(f"{BASE_URL}/auth/login")
assert login.status_code == 200
finally:
_save_settings(page, maintenance_mode="0")
View File
+97
View File
@@ -0,0 +1,97 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timezone
from starlette.requests import Request
from tests.conftest import BASE_URL
from devplacepy.database import get_table, set_setting
from devplacepy.utils import generate_uid
from devplacepy.services.openai_gateway import GatewayService
from devplacepy.services.openai_gateway.analytics import build_user_usage
from devplacepy.services.devii.config import (
build_settings,
FIELD_AI_URL,
FIELD_AI_MODEL,
FIELD_AI_KEY,
FIELD_MAX_ITERATIONS,
FIELD_PLAN_REQUIRED,
FIELD_VERIFY_REQUIRED,
)
def _now_iso_ai_usage_profile():
return datetime.now(timezone.utc).isoformat()
def _make_user_ai_usage_profile(role="Member"):
username = f"aiu_{generate_uid()[:8]}"
api_key = generate_uid()
uid = generate_uid()
get_table("users").insert(
{
"uid": uid,
"username": username,
"email": f"{username}@t.dev",
"api_key": api_key,
"role": role,
"is_active": True,
}
)
return uid, username, api_key
def _make_request_ai_usage_profile(headers):
raw = [(k.lower().encode(), v.encode()) for k, v in headers.items()]
return Request(
{
"type": "http",
"method": "POST",
"path": "/openai/v1/chat/completions",
"query_string": b"",
"headers": raw,
"state": {},
}
)
def _seed_gateway(uid, n=3, cost=0.01, kind="user"):
table = get_table("gateway_usage_ledger")
for _ in range(n):
table.insert(
{
"created_at": _now_iso_ai_usage_profile(),
"owner_kind": kind,
"owner_id": uid,
"backend": "chat",
"model": "molodetz",
"success": 1,
"status_code": 200,
"prompt_tokens": 1000,
"completion_tokens": 300,
"total_tokens": 1300,
"upstream_latency_ms": 1500.0,
"tokens_per_second": 40.0,
"cost_usd": cost,
}
)
def _devii_cfg():
return {
FIELD_AI_URL: "",
FIELD_AI_MODEL: "",
FIELD_AI_KEY: "INTERNAL-KEY",
FIELD_MAX_ITERATIONS: "40",
FIELD_PLAN_REQUIRED: "1",
FIELD_VERIFY_REQUIRED: "1",
}
def _goto_profile(page, username):
page.goto(f"{BASE_URL}/profile/{username}", wait_until="domcontentloaded")
def test_admin_user_ai_usage_endpoint_returns_data(alice, seeded_db):
page, _ = alice
alice_uid = get_table("users").find_one(username=seeded_db["alice"]["username"])[
"uid"
]
resp = page.request.get(f"{BASE_URL}/admin/users/{alice_uid}/ai-usage?hours=24")
assert resp.status == 200
data = resp.json()
assert data["owner_id"] == alice_uid
assert "cost" in data and "requests" in data and "tokens" in data
def test_member_forbidden_from_user_ai_usage_endpoint(bob, seeded_db):
page, _ = bob
bob_uid = get_table("users").find_one(username=seeded_db["bob"]["username"])["uid"]
resp = page.request.get(f"{BASE_URL}/admin/users/{bob_uid}/ai-usage")
assert resp.status == 403
+366
View File
@@ -0,0 +1,366 @@
# retoor <retoor@molodetz.nl>
from uuid import uuid4
from datetime import datetime, timezone
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
def seed_admin_news(count=3):
news_table = get_table("news")
news_table.delete()
for i in range(count):
eid = f"admin_test_news_{i}"
if news_table.find_one(external_id=eid):
continue
uid = str(uuid4())
title = f"Admin News Article {i}"
slug = make_combined_slug(title, uid)
news_table.insert(
{
"uid": uid,
"slug": slug,
"title": title,
"external_id": eid,
"grade": 5 + i,
"status": "draft" if i == 0 else "published",
"show_on_landing": 1 if i == 1 else 0,
"source_name": "AdminTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": f"Test article {i} for admin tests.",
}
)
def seed_extra_users(count=30):
users = get_table("users")
existing_count = len(list(users.all()))
if existing_count > 5:
return
for i in range(count):
uid = str(uuid4())
users.insert(
{
"uid": uid,
"username": f"pagu_{i:04d}",
"email": f"pagu{i:04d}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
def _seed_target_user():
uid = str(uuid4())
get_table("users").insert(
{
"uid": uid,
"username": f"target_{uid[:8]}",
"email": f"target_{uid[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
import time
import pytest
import requests
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
JSON_audit_log = {"Accept": "application/json"}
_counter_audit_log = [0]
@pytest.fixture(scope="module", autouse=True)
def _audit_test_settings(app_server):
# On a fresh DB init_db skips seeding the operational/upload settings (its
# `tables` snapshot predates site_settings creation), so those rows are
# absent and the admin settings form would INSERT them as "" - which both
# closes registration and makes consumers that do int("") crash. Seed sane
# values here so the form's empty submissions are skipped (existing key), and
# lift the per-IP rate limit since this file fires many mutating requests.
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(name):
# the user is created by the server subprocess; refresh the test-process
# SQLite snapshot before reading it back across the process boundary.
refresh_snapshot()
return get_table("users").find_one(username=name)
def _unique(prefix="au"):
_counter_audit_log[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
def _member():
name = _unique("aumem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _member_key():
_, name = _member()
return _db_user(name)["api_key"]
def _admin(seeded_db):
# authenticate via the seeded admin's API key (header auth) rather than a
# login POST - GET reads are exempt from the rate limiter, so reusing this
# across the file's many tests never counts against the per-IP write budget.
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def _audit(admin, **params):
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
assert r.status_code == 200, r.text[:300]
return r.json()
def _find(admin, event_key, predicate):
data = _audit(admin, event_key=event_key)
for entry in data["entries"]:
if predicate(entry):
return entry
return None
def _new_post(session, body="audited post body here"):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
).json()["data"]
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupr"),
"description": "audited project description text",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def _seed_news_audit_log():
uid = generate_uid()
title = _unique("aunews")
get_table("news").insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": uid,
"status": "draft",
"featured": 0,
"show_on_landing": 0,
"grade": 5,
"source_name": "AuditTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "audited news article",
}
)
refresh_snapshot()
return uid
import io
import uuid
from PIL import Image
JSON_media = {"Accept": "application/json"}
def _png_bytes_media(color=(200, 30, 30)):
buf = io.BytesIO()
Image.new("RGB", (8, 8), color).save(buf, "PNG")
return buf.getvalue()
def _signup_media(prefix="media"):
s = requests.Session()
name = f"{prefix}_{uuid.uuid4().hex[:10]}"
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@test.devplace",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _login_media(seeded_db, who):
s = requests.Session()
creds = seeded_db[who]
s.post(
f"{BASE_URL}/auth/login",
data={"email": creds["email"], "password": creds["password"]},
allow_redirects=True,
)
return s
def _upload_media(s, name="pic.png", content=None, mime="image/png"):
files = {"file": (name, content if content is not None else _png_bytes_media(), mime)}
r = s.post(f"{BASE_URL}/uploads/upload", files=files)
assert r.status_code == 201, r.text
return r.json()["uid"]
def _create_post_media(s, attachment_uids, title="media post"):
r = s.post(
f"{BASE_URL}/posts/create",
data={
"content": "A post that carries media for the gallery tests.",
"title": title,
"topic": "random",
"attachment_uids": attachment_uids,
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert "/posts/" in r.url, r.url
return r.url
def _create_project_media(s, attachment_uids, title="Media Project"):
r = s.post(
f"{BASE_URL}/projects/create",
data={
"title": title,
"description": "Project with media for gallery tests.",
"project_type": "software",
"platforms": "linux",
"status": "In Development",
"attachment_uids": attachment_uids,
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert "/projects/" in r.url, r.url
return r.url
def _media_json(session, username):
r = session.get(f"{BASE_URL}/profile/{username}?tab=media", headers=JSON_media)
assert r.status_code == 200, r.text[:300]
return r.json()
def _media_uids(session, username):
return [m["uid"] for m in _media_json(session, username)["media"]]
def _seed_media_via_browser(page, title="ui media"):
up = page.request.post(
f"{BASE_URL}/uploads/upload",
multipart={
"file": {
"name": "pic.png",
"mimeType": "image/png",
"buffer": _png_bytes_media(),
}
},
)
assert up.ok, up.text()
uid = up.json()["uid"]
post = page.request.post(
f"{BASE_URL}/posts/create",
form={
"content": "media for the ui gallery test",
"title": title,
"topic": "random",
"attachment_uids": uid,
},
)
assert post.ok, post.text()
return uid
def _media_uids_via_page(page, username):
resp = page.request.get(f"{BASE_URL}/profile/{username}?tab=media", headers=JSON_media)
return [m["uid"] for m in resp.json()["media"]]
def test_admin_users_loads(alice):
page, _ = alice
page.goto(f"{BASE_URL}/admin/users", wait_until="domcontentloaded")
assert page.is_visible("text=Users")
assert page.is_visible("text=alice_test")
def test_admin_users_pagination(alice):
page, _ = alice
import urllib.request
import urllib.parse
for i in range(30):
data = urllib.parse.urlencode(
{
"username": f"pagu_{i:04d}",
"email": f"pagu{i:04d}@test.devplace",
"password": "testpass123",
"confirm_password": "testpass123",
}
).encode()
try:
urllib.request.urlopen(
urllib.request.Request(
f"{BASE_URL}/auth/signup",
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
)
except Exception:
pass
page.goto(f"{BASE_URL}/admin/users", wait_until="domcontentloaded")
assert "Page 1" in page.text_content(".pagination-info")
page.goto(f"{BASE_URL}/admin/users?page=2", wait_until="domcontentloaded")
assert "Page 2" in page.text_content(".pagination-info")
def test_admin_change_user_role(alice):
page, _ = alice
uid = _seed_target_user()
page.goto(f"{BASE_URL}/admin/users", wait_until="domcontentloaded")
sel = f"form[action='/admin/users/{uid}/role'] select[name='role']"
page.locator(sel).wait_for(state="visible")
page.select_option(sel, "admin")
page.locator(".dialog-overlay.visible .dialog-confirm").click()
page.wait_for_url("**/admin/users**", wait_until="domcontentloaded")
page.goto(f"{BASE_URL}/admin/users", wait_until="domcontentloaded")
assert page.locator(sel).input_value() == "admin"
def test_admin_toggle_user_active(alice):
page, _ = alice
uid = _seed_target_user()
page.goto(f"{BASE_URL}/admin/users", wait_until="domcontentloaded")
btn = f"form[action='/admin/users/{uid}/toggle'] button"
before = page.locator(btn).inner_text()
page.locator(btn).click()
page.locator(".dialog-overlay.visible .dialog-confirm").click()
page.wait_for_url("**/admin/users**", wait_until="domcontentloaded")
assert page.locator(btn).inner_text() != before
def test_admin_password_change_redirect(alice):
page, _ = alice
uid = _seed_target_user()
page.goto(f"{BASE_URL}/admin/users", wait_until="domcontentloaded")
form_sel = f"form[action='/admin/users/{uid}/password']"
pw_form = page.locator(form_sel)
if pw_form.count() > 0:
page.locator(f"button[data-toggle='pw-{uid}']").click()
pw_form.locator("input[name='password']").fill("newpass123")
pw_form.locator("button[type='submit']").click()
page.wait_for_url("**/admin/users**", wait_until="domcontentloaded")
def test_audit_log_sidebar_link_present(alice):
page, _ = alice
page.goto(f"{BASE_URL}/admin/users", wait_until="domcontentloaded")
link = page.locator(".sidebar-card a.sidebar-link[href='/admin/audit-log']")
assert link.count() == 1
assert "Audit Log" in link.inner_text()
def test_ui_admin_media_sidebar_and_trash(alice):
page, _ = alice
page.goto(f"{BASE_URL}/admin/users", wait_until="domcontentloaded")
link = page.locator(".sidebar-link[href='/admin/media']")
assert link.is_visible()
link.click()
page.wait_for_url("**/admin/media", wait_until="domcontentloaded")
assert page.is_visible("text=Media trash")
View File
View File
+108
View File
@@ -0,0 +1,108 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timezone
from starlette.requests import Request
from tests.conftest import BASE_URL
from devplacepy.database import get_table, set_setting
from devplacepy.utils import generate_uid
from devplacepy.services.openai_gateway import GatewayService
from devplacepy.services.openai_gateway.analytics import build_user_usage
from devplacepy.services.devii.config import (
build_settings,
FIELD_AI_URL,
FIELD_AI_MODEL,
FIELD_AI_KEY,
FIELD_MAX_ITERATIONS,
FIELD_PLAN_REQUIRED,
FIELD_VERIFY_REQUIRED,
)
def _now_iso_ai_usage_profile():
return datetime.now(timezone.utc).isoformat()
def _make_user_ai_usage_profile(role="Member"):
username = f"aiu_{generate_uid()[:8]}"
api_key = generate_uid()
uid = generate_uid()
get_table("users").insert(
{
"uid": uid,
"username": username,
"email": f"{username}@t.dev",
"api_key": api_key,
"role": role,
"is_active": True,
}
)
return uid, username, api_key
def _make_request_ai_usage_profile(headers):
raw = [(k.lower().encode(), v.encode()) for k, v in headers.items()]
return Request(
{
"type": "http",
"method": "POST",
"path": "/openai/v1/chat/completions",
"query_string": b"",
"headers": raw,
"state": {},
}
)
def _seed_gateway(uid, n=3, cost=0.01, kind="user"):
table = get_table("gateway_usage_ledger")
for _ in range(n):
table.insert(
{
"created_at": _now_iso_ai_usage_profile(),
"owner_kind": kind,
"owner_id": uid,
"backend": "chat",
"model": "molodetz",
"success": 1,
"status_code": 200,
"prompt_tokens": 1000,
"completion_tokens": 300,
"total_tokens": 1300,
"upstream_latency_ms": 1500.0,
"tokens_per_second": 40.0,
"cost_usd": cost,
}
)
def _devii_cfg():
return {
FIELD_AI_URL: "",
FIELD_AI_MODEL: "",
FIELD_AI_KEY: "INTERNAL-KEY",
FIELD_MAX_ITERATIONS: "40",
FIELD_PLAN_REQUIRED: "1",
FIELD_VERIFY_REQUIRED: "1",
}
def _goto_profile(page, username):
page.goto(f"{BASE_URL}/profile/{username}", wait_until="domcontentloaded")
def test_admin_sees_full_ai_usage_on_profile(alice):
page, user = alice
_goto_profile(page, user["username"])
page.locator("[data-ai-usage]").wait_for(state="visible")
assert (
page.locator("[data-ai-usage] .ai-usage-title", has_text="AI usage").count()
== 1
)
assert page.locator("[data-ai-usage] [data-ai-quota]").count() == 1
def test_member_sees_only_quota_percentage(bob):
page, user = bob
_goto_profile(page, user["username"])
card = page.locator(".ai-quota-only")
card.wait_for(state="visible")
assert (
page.locator(".ai-quota-only .ai-usage-title", has_text="AI quota").count() == 1
)
assert "%" in page.locator(".ai-quota-only .ai-quota-pct").inner_text()
assert page.locator("[data-ai-usage]").count() == 0
def test_member_cannot_see_ai_on_another_profile(bob, seeded_db):
page, _ = bob
_goto_profile(page, seeded_db["alice"]["username"])
assert page.locator("[data-ai-usage]").count() == 0
assert page.locator("[data-ai-quota]").count() == 0
View File
+22
View File
@@ -0,0 +1,22 @@
# retoor <retoor@molodetz.nl>
import hashlib
import time
from datetime import datetime, timedelta, timezone
from playwright.sync_api import expect
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.utils import hash_password, generate_uid
def test_forgot_password_page_loads(page, app_server):
page.goto(f"{BASE_URL}/auth/forgot-password", wait_until="domcontentloaded")
assert page.is_visible("input#email")
assert page.is_visible("button:has-text('Send Reset Link')")
def test_forgot_password_submit_shows_sent(page, app_server):
page.goto(f"{BASE_URL}/auth/forgot-password", wait_until="domcontentloaded")
page.fill("#email", "anybody@example.com")
page.click("button:has-text('Send Reset Link')")
page.locator(".auth-success").wait_for(state="visible")
+222
View File
@@ -0,0 +1,222 @@
# retoor <retoor@molodetz.nl>
import hashlib
import time
from datetime import datetime, timedelta, timezone
from playwright.sync_api import expect
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.utils import hash_password, generate_uid
from uuid import uuid4
from datetime import datetime, timezone
import requests
def _seed_news_seo():
from datetime import datetime, timezone
from devplacepy.database import get_table
from devplacepy.utils import generate_uid, make_combined_slug
uid = generate_uid()
title = f"SEO Test News Article {uid.split('-')[-1]}"
slug = make_combined_slug(title, uid)
get_table("news").insert(
{
"uid": uid,
"slug": slug,
"title": title,
"description": "A seeded news article for SEO tests.",
"content": "Body content for the seeded article.",
"url": "https://example.com/article",
"source_name": "ExampleSource",
"status": "published",
"synced_at": datetime.now(timezone.utc).isoformat(),
"show_on_landing": 0,
"grade": 8,
}
)
return slug, uid
def _seed_owner():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
uid = str(uuid4())
get_table("users").insert(
{
"uid": uid,
"username": f"seo_{uid[:8]}",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def _seed_post_seo(image=None):
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Post", uid)
get_table("posts").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Post",
"content": "Body text for the SEO detail post.",
"topic": "general",
"project_uid": None,
"image": image,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_gist():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Gist", uid)
get_table("gists").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Gist",
"description": "Gist description for SEO tests.",
"source_code": "print('seo')",
"language": "python",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_project():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Project", uid)
get_table("projects").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Project",
"description": "Project description for SEO tests.",
"project_type": "software",
"platforms": "Linux",
"status": "Released",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_news_image(news_uid):
from devplacepy.database import get_table
get_table("news_images").insert(
{
"news_uid": news_uid,
"url": "https://example.com/seo-news-image.jpg",
}
)
def _seed_feed_posts(count):
from datetime import datetime, timezone, timedelta
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
topic = f"seopag{owner[:8]}"
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
posts = get_table("posts")
for i in range(count):
uid = str(uuid4())
posts.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(f"seo pag {i}", uid),
"title": None,
"content": f"seo pag post {i}",
"topic": topic,
"project_uid": None,
"image": None,
"stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
}
)
return topic
def test_login_page_loads(page, app_server):
page.goto(f"{BASE_URL}/auth/login", wait_until="domcontentloaded")
assert page.is_visible("h2:has-text('Welcome Back')")
assert page.is_visible("#email")
assert page.is_visible("#password")
assert page.is_visible("button:has-text('Sign in')")
def test_login_nonexistent_email(page, app_server):
page.goto(f"{BASE_URL}/auth/login", wait_until="domcontentloaded")
page.fill("#email", "nobody@nowhere.devplace")
page.fill("#password", "secret123")
page.click("button:has-text('Sign in')")
expect(page.locator("text=Invalid email or password")).to_be_visible()
def test_signup_link_from_login(page, app_server):
page.goto(f"{BASE_URL}/auth/login", wait_until="domcontentloaded")
page.click("a:has-text('Create new account')")
page.wait_for_url("**/auth/signup", wait_until="domcontentloaded")
def test_login_next_redirects_to_target(page, app_server, seeded_db):
page.goto(f"{BASE_URL}/auth/login?next=/gists", wait_until="domcontentloaded")
page.fill("#email", seeded_db["alice"]["email"])
page.fill("#password", seeded_db["alice"]["password"])
page.click("button:has-text('Sign in')")
page.wait_for_url("**/gists", wait_until="domcontentloaded")
def test_login_next_rejects_external(page, app_server, seeded_db):
page.goto(
f"{BASE_URL}/auth/login?next=https://evil.example.com",
wait_until="domcontentloaded",
)
page.fill("#email", seeded_db["alice"]["email"])
page.fill("#password", seeded_db["alice"]["password"])
page.click("button:has-text('Sign in')")
page.wait_for_url("**/feed", wait_until="domcontentloaded")
def test_landing_nav_to_login(page, app_server):
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
page.click("a:has-text('Login')")
page.wait_for_url(f"{BASE_URL}/auth/login", wait_until="domcontentloaded")
assert page.is_visible("h2:has-text('Welcome Back')")
def test_landing_log_in_link(page, app_server):
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
page.click("text=Log In")
page.wait_for_url(f"{BASE_URL}/auth/login", wait_until="domcontentloaded")
def test_login_page_is_noindex(page, app_server):
page.goto(f"{BASE_URL}/auth/login", wait_until="domcontentloaded")
meta = page.locator('meta[name="robots"]')
content = meta.get_attribute("content")
assert content and "noindex" in content
+80
View File
@@ -0,0 +1,80 @@
# retoor <retoor@molodetz.nl>
import hashlib
import time
from datetime import datetime, timedelta, timezone
from playwright.sync_api import expect
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.utils import hash_password, generate_uid
def test_login_success(page, app_server):
page.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
page.fill("#username", "login_user")
page.fill("#email", "login@test.devplace")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.click("button:has-text('Create account')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
page.goto(f"{BASE_URL}/auth/logout", wait_until="domcontentloaded")
page.goto(f"{BASE_URL}/auth/login", wait_until="domcontentloaded")
page.fill("#email", "login@test.devplace")
page.fill("#password", "secret123")
page.click("button:has-text('Sign in')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
assert page.is_visible("text=login_user")
def test_login_wrong_password(page, app_server):
page.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
page.fill("#username", "wrongpw_user")
page.fill("#email", "wrongpw@test.devplace")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.click("button:has-text('Create account')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
page.goto(f"{BASE_URL}/auth/logout", wait_until="domcontentloaded")
page.goto(f"{BASE_URL}/auth/login", wait_until="domcontentloaded")
page.fill("#email", "wrongpw@test.devplace")
page.fill("#password", "badpassword")
page.click("button:has-text('Sign in')")
expect(page.locator("text=Invalid email or password")).to_be_visible()
def test_login_remember_me(page, app_server):
page.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
page.fill("#username", "remember_user")
page.fill("#email", "remember@test.devplace")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.click("button:has-text('Create account')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
page.goto(f"{BASE_URL}/auth/logout", wait_until="domcontentloaded")
page.goto(f"{BASE_URL}/auth/login", wait_until="domcontentloaded")
page.fill("#email", "remember@test.devplace")
page.fill("#password", "secret123")
page.check("input[name='remember_me']")
page.click("button:has-text('Sign in')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
assert page.is_visible("text=remember_user")
def test_logout(page, app_server):
page.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
page.fill("#username", "logout_user")
page.fill("#email", "logout@test.devplace")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.click("button:has-text('Create account')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
page.goto(f"{BASE_URL}/auth/logout", wait_until="domcontentloaded")
page.wait_for_url("**/", wait_until="domcontentloaded")
assert page.is_visible("text=Join DevPlace Free")
+72
View File
@@ -0,0 +1,72 @@
# retoor <retoor@molodetz.nl>
import hashlib
import time
from datetime import datetime, timedelta, timezone
from playwright.sync_api import expect
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.utils import hash_password, generate_uid
def test_reset_password_page_loads(page, app_server):
page.goto(
f"{BASE_URL}/auth/reset-password/sometoken", wait_until="domcontentloaded"
)
assert page.is_visible("input#password")
assert page.is_visible("input#confirm_password")
def test_reset_password_mismatch_shows_error(page, app_server):
page.goto(
f"{BASE_URL}/auth/reset-password/sometoken", wait_until="domcontentloaded"
)
page.fill("#password", "abcdef1")
page.fill("#confirm_password", "different1")
page.click("button:has-text('Reset Password')")
page.locator(".auth-error").wait_for(state="visible")
def test_reset_password_full_flow(page, app_server):
uname = f"reset_{int(time.time() * 1000)}"
email = f"{uname}@t.dev"
uid = generate_uid()
get_table("users").insert(
{
"uid": uid,
"username": uname,
"email": email,
"password_hash": hash_password("oldpass123"),
"bio": "",
"location": "",
"git_link": "",
"website": "",
"role": "Member",
"is_active": True,
"level": 1,
"xp": 0,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
token = f"tok{uid[:8]}"
get_table("password_resets").insert(
{
"uid": generate_uid(),
"user_uid": uid,
"token": hashlib.sha256(token.encode()).hexdigest(),
"expires_at": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(),
"used": False,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
page.goto(f"{BASE_URL}/auth/reset-password/{token}", wait_until="domcontentloaded")
page.fill("#password", "newpass456")
page.fill("#confirm_password", "newpass456")
page.click("button:has-text('Reset Password')")
page.wait_for_url("**/auth/login", wait_until="domcontentloaded")
page.fill("#email", email)
page.fill("#password", "newpass456")
page.click("button:has-text('Sign in')")
page.wait_for_url("**/feed", wait_until="domcontentloaded")
assert "/feed" in page.url
+437
View File
@@ -0,0 +1,437 @@
# retoor <retoor@molodetz.nl>
import hashlib
import time
from datetime import datetime, timedelta, timezone
from playwright.sync_api import expect
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.utils import hash_password, generate_uid
from uuid import uuid4
from datetime import datetime, timezone
import requests
DEFAULT_MAINTENANCE_MESSAGE = (
"DevPlace is undergoing scheduled maintenance. Please check back shortly."
)
OPERATIONAL_FIELDS = (
"rate_limit_per_minute",
"rate_limit_window_seconds",
"session_max_age_days",
"session_remember_days",
"registration_open",
"maintenance_mode",
"maintenance_message",
)
def _save_settings(page, **fields):
page.goto(f"{BASE_URL}/admin/settings", wait_until="domcontentloaded")
for name, value in fields.items():
locator = page.locator(f"#{name}")
tag = locator.evaluate("el => el.tagName.toLowerCase()")
if tag == "select":
page.select_option(f"#{name}", value)
else:
locator.fill(value)
page.click("button:has-text('Save Settings')")
page.wait_for_url("**/admin/settings", wait_until="domcontentloaded")
def _seed_news_seo():
from datetime import datetime, timezone
from devplacepy.database import get_table
from devplacepy.utils import generate_uid, make_combined_slug
uid = generate_uid()
title = f"SEO Test News Article {uid.split('-')[-1]}"
slug = make_combined_slug(title, uid)
get_table("news").insert(
{
"uid": uid,
"slug": slug,
"title": title,
"description": "A seeded news article for SEO tests.",
"content": "Body content for the seeded article.",
"url": "https://example.com/article",
"source_name": "ExampleSource",
"status": "published",
"synced_at": datetime.now(timezone.utc).isoformat(),
"show_on_landing": 0,
"grade": 8,
}
)
return slug, uid
def _seed_owner():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
uid = str(uuid4())
get_table("users").insert(
{
"uid": uid,
"username": f"seo_{uid[:8]}",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def _seed_post_seo(image=None):
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Post", uid)
get_table("posts").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Post",
"content": "Body text for the SEO detail post.",
"topic": "general",
"project_uid": None,
"image": image,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_gist():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Gist", uid)
get_table("gists").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Gist",
"description": "Gist description for SEO tests.",
"source_code": "print('seo')",
"language": "python",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_project():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Project", uid)
get_table("projects").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Project",
"description": "Project description for SEO tests.",
"project_type": "software",
"platforms": "Linux",
"status": "Released",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_news_image(news_uid):
from devplacepy.database import get_table
get_table("news_images").insert(
{
"news_uid": news_uid,
"url": "https://example.com/seo-news-image.jpg",
}
)
def _seed_feed_posts(count):
from datetime import datetime, timezone, timedelta
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
topic = f"seopag{owner[:8]}"
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
posts = get_table("posts")
for i in range(count):
uid = str(uuid4())
posts.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(f"seo pag {i}", uid),
"title": None,
"content": f"seo pag post {i}",
"topic": topic,
"project_uid": None,
"image": None,
"stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
}
)
return topic
def test_signup_page_loads(page, app_server):
page.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
assert page.is_visible("h2:has-text('Join the Community')")
assert page.is_visible("#username")
assert page.is_visible("#email")
assert page.is_visible("#password")
assert page.is_visible("#confirm_password")
def test_signup_success(page, app_server):
page.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
page.fill("#username", "fresh_user")
page.fill("#email", "fresh@test.devplace")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.click("button:has-text('Create account')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
assert page.is_visible("text=fresh_user")
def test_signup_existing_username(page, app_server):
page.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
page.fill("#username", "dup_user")
page.fill("#email", "dup1@test.devplace")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.click("button:has-text('Create account')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
page.context.clear_cookies()
page.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
page.fill("#username", "dup_user")
page.fill("#email", "dup2@test.devplace")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.click("button:has-text('Create account')")
expect(page.locator("text=Username already taken")).to_be_visible()
def test_signup_existing_email(page, app_server):
page.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
page.fill("#username", "email_dup1")
page.fill("#email", "sameemail@test.devplace")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.click("button:has-text('Create account')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
page.context.clear_cookies()
page.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
page.fill("#username", "email_dup2")
page.fill("#email", "sameemail@test.devplace")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.click("button:has-text('Create account')")
expect(page.locator("text=Email already registered")).to_be_visible()
def test_signup_password_mismatch(page, app_server):
page.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
page.fill("#username", "mismatch_user")
page.fill("#email", "mismatch@test.devplace")
page.fill("#password", "secret123")
page.fill("#confirm_password", "different456")
page.click("button:has-text('Create account')")
expect(page.locator("text=Passwords do not match")).to_be_visible()
def test_signup_short_password(page, app_server):
page.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
page.fill("#username", "shortpw_user")
page.fill("#email", "shortpw@test.devplace")
page.fill("#password", "ab")
page.fill("#confirm_password", "ab")
page.click("button:has-text('Create account')")
expect(page.locator("text=Password must be at least 6 characters")).to_be_visible()
def test_signup_invalid_username(page, app_server):
page.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
page.fill("#username", "ab")
page.fill("#email", "shortname@test.devplace")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.click("button:has-text('Create account')")
expect(
page.locator("text=Username must be between 3 and 32 characters")
).to_be_visible()
def test_login_link_from_signup(page, app_server):
page.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
page.click("a:has-text('Sign in instead')")
page.wait_for_url("**/auth/login", wait_until="domcontentloaded")
def test_password_toggle(page, app_server):
page.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
pw_input = page.locator("#password")
toggle = page.locator(".auth-toggle-pw").first
assert pw_input.get_attribute("type") == "password"
toggle.click()
assert pw_input.get_attribute("type") == "text"
toggle.click()
assert pw_input.get_attribute("type") == "password"
def test_landing_nav_to_signup(page, app_server):
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
page.click("a:has-text('Sign Up')")
page.wait_for_url(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
assert page.is_visible("h2:has-text('Join the Community')")
def test_landing_authenticated_shows_dashboard(page, app_server):
page.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
page.fill("#username", "home_user")
page.fill("#email", "home@test.devplace")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
with page.expect_navigation(timeout=10000):
page.click("button:has-text('Create account')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
assert page.url.rstrip("/") == BASE_URL.rstrip("/")
assert page.is_visible("text=Welcome back")
assert page.is_visible("text=home_user")
feed_cta = page.locator("a.landing-cta:has-text('Go to your feed')")
assert feed_cta.is_visible()
assert feed_cta.get_attribute("href") == "/feed"
assert not page.is_visible("text=Join DevPlace Free")
def test_registration_closed_blocks_signup(alice):
page, _ = alice
try:
_save_settings(page, registration_open="0")
signup_page = requests.get(f"{BASE_URL}/auth/signup")
assert signup_page.status_code == 200
assert "Registration is currently closed" in signup_page.text
attempt = requests.post(
f"{BASE_URL}/auth/signup",
data={
"username": "closed_signup",
"email": "closed_signup@test.devplace",
"password": "secret123",
"confirm_password": "secret123",
},
)
assert "Registration is currently closed" in attempt.text
assert get_table("users").find_one(username="closed_signup") is None
finally:
_save_settings(page, registration_open="1")
def test_registration_open_allows_signup(alice):
page, _ = alice
try:
_save_settings(page, registration_open="1")
signup_page = requests.get(f"{BASE_URL}/auth/signup")
assert "Registration is currently closed" not in signup_page.text
assert 'name="username"' in signup_page.text
finally:
_save_settings(page, registration_open="1")
def test_session_length_setting_applies(alice, browser):
page, _ = alice
context = browser.new_context(viewport={"width": 1400, "height": 900})
guest = context.new_page()
guest.set_default_timeout(15000)
try:
_save_settings(page, session_max_age_days="1")
username = f"sesslen_{int(time.time() * 1000)}"
guest.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
guest.fill("#username", username)
guest.fill("#email", f"{username}@test.devplace")
guest.fill("#password", "secret123")
guest.fill("#confirm_password", "secret123")
guest.click("button:has-text('Create account')")
guest.wait_for_url("**/feed", wait_until="domcontentloaded")
session_cookie = next(c for c in context.cookies() if c["name"] == "session")
remaining = session_cookie["expires"] - time.time()
assert 0 < remaining < 2 * 86400, remaining
finally:
guest.close()
context.close()
_save_settings(page, session_max_age_days="7")
def test_feed_page_has_unique_title(page, app_server):
page.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
page.fill("#username", "seo_title_test")
page.fill("#email", "seo_title@test.dev")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.click("button:has-text('Create account')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
title = page.title()
assert title and "Feed" in title
def test_signup_page_is_noindex(page, app_server):
page.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
meta = page.locator('meta[name="robots"]')
content = meta.get_attribute("content")
assert content and "noindex" in content
def test_feed_page_has_canonical(page, app_server):
page.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
page.fill("#username", "seo_canon_test")
page.fill("#email", "seo_canon@test.dev")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.click("button:has-text('Create account')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
link = page.locator('link[rel="canonical"]')
href = link.get_attribute("href")
assert href and href.startswith("http")
def test_feed_page_has_og_tags(page, app_server):
page.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
page.fill("#username", "seo_og_test")
page.fill("#email", "seo_og@test.dev")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.click("button:has-text('Create account')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
og = page.locator('meta[property="og:title"]')
content = og.get_attribute("content")
assert content and len(content) > 0
def test_feed_page_has_twitter_card(page, app_server):
page.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
page.fill("#username", "seo_twit_test")
page.fill("#email", "seo_twit@test.dev")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.click("button:has-text('Create account')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
card = page.locator('meta[name="twitter:card"]')
content = card.get_attribute("content")
assert content == "summary_large_image"
View File
+140
View File
@@ -0,0 +1,140 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
JSON_audit_log = {"Accept": "application/json"}
_counter_audit_log = [0]
@pytest.fixture(scope="module", autouse=True)
def _audit_test_settings(app_server):
# On a fresh DB init_db skips seeding the operational/upload settings (its
# `tables` snapshot predates site_settings creation), so those rows are
# absent and the admin settings form would INSERT them as "" - which both
# closes registration and makes consumers that do int("") crash. Seed sane
# values here so the form's empty submissions are skipped (existing key), and
# lift the per-IP rate limit since this file fires many mutating requests.
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(name):
# the user is created by the server subprocess; refresh the test-process
# SQLite snapshot before reading it back across the process boundary.
refresh_snapshot()
return get_table("users").find_one(username=name)
def _unique(prefix="au"):
_counter_audit_log[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
def _member():
name = _unique("aumem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _member_key():
_, name = _member()
return _db_user(name)["api_key"]
def _admin(seeded_db):
# authenticate via the seeded admin's API key (header auth) rather than a
# login POST - GET reads are exempt from the rate limiter, so reusing this
# across the file's many tests never counts against the per-IP write budget.
key = _db_user("alice_test")["api_key"]
s = requests.Session()
s.headers.update({"X-API-KEY": key})
return s
def _audit(admin, **params):
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
assert r.status_code == 200, r.text[:300]
return r.json()
def _find(admin, event_key, predicate):
data = _audit(admin, event_key=event_key)
for entry in data["entries"]:
if predicate(entry):
return entry
return None
def _new_post(session, body="audited post body here"):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
).json()["data"]
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupr"),
"description": "audited project description text",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def _seed_news_audit_log():
uid = generate_uid()
title = _unique("aunews")
get_table("news").insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": uid,
"status": "draft",
"featured": 0,
"show_on_landing": 0,
"grade": 5,
"source_name": "AuditTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "audited news article",
}
)
refresh_snapshot()
return uid
def test_audit_log_pagination_preserves_filters(alice):
page, _ = alice
for _ in range(30):
requests.get(f"{BASE_URL}/bookmarks/saved", allow_redirects=False)
page.goto(
f"{BASE_URL}/admin/audit-log?event_key=security.authz.denied",
wait_until="domcontentloaded",
)
next_link = page.locator("a.pagination-btn:has-text('Next')")
assert next_link.count() > 0
href = next_link.first.get_attribute("href")
assert "event_key=security.authz.denied" in href
assert "page=2" in href
next_link.first.click()
page.wait_for_url("**page=2**", wait_until="domcontentloaded")
assert "event_key=security.authz.denied" in page.url
# the filter is still applied on page 2: every visible event badge matches
badges = page.locator(
"table.admin-table tbody tr td .audit-badge"
).all_inner_texts()
event_badges = [b for b in badges if "." in b and b.islower()]
assert event_badges
assert all(b == "security.authz.denied" for b in event_badges)
View File
+66
View File
@@ -0,0 +1,66 @@
# retoor <retoor@molodetz.nl>
from tests.conftest import BASE_URL
def test_bugs_page_loads(alice):
page, _ = alice
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
assert page.is_visible("h1:has-text('Bug Reports')")
def test_bugs_filters_visible(alice):
page, _ = alice
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
assert page.locator(".bugs-filter:has-text('Open')").is_visible()
assert page.locator(".bugs-filter:has-text('Closed')").is_visible()
assert page.locator(".bugs-filter:has-text('All')").is_visible()
def test_bugs_not_configured_notice(alice):
page, _ = alice
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
assert page.is_visible("text=The bug tracker is not configured yet.")
def test_bugs_create_modal(alice):
page, _ = alice
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
page.click("button:has-text('Report Bug')")
assert page.is_visible("h3:has-text('Report a Bug')")
def test_bugs_create_cancel(alice):
page, _ = alice
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
page.click("button:has-text('Report Bug')")
page.click("button:has-text('Cancel')")
modal = page.locator("#create-bug-modal")
assert not modal.is_visible()
def test_bugs_unauth(page):
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
assert page.is_visible("h1:has-text('Bug Reports')")
assert page.locator("a.login-required:has-text('Report Bug')").is_visible()
assert page.locator("#create-bug-modal").count() == 0
def test_bug_button_icon_spacing(alice):
page, _ = alice
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
html = page.locator("button:has-text('Report Bug')").inner_html()
assert "</span> Report Bug" in html
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
landing = page.locator("a:has-text('Bug Report')").inner_html()
assert "</span> Bug Report" in landing
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
footer = page.locator("a:has-text('Bug Report')").inner_html()
assert "</span> Bug Report" in footer
def test_bug_detail_not_configured(alice):
page, _ = alice
page.goto(f"{BASE_URL}/bugs/999", wait_until="domcontentloaded")
assert page.is_visible("h1.error-code:has-text('404')")
assert page.is_visible("text=Page not found")
+10
View File
@@ -0,0 +1,10 @@
# retoor <retoor@molodetz.nl>
from tests.conftest import BASE_URL
def test_bug_job_status_not_found(alice):
page, _ = alice
page.goto(f"{BASE_URL}/bugs/jobs/nonexistent-job", wait_until="domcontentloaded")
assert page.is_visible("h1.error-code:has-text('404')")
assert page.is_visible("text=Page not found")
View File
+77
View File
@@ -0,0 +1,77 @@
# retoor <retoor@molodetz.nl>
import time
import requests
from playwright.sync_api import expect
from tests.conftest import BASE_URL
from devplacepy.database import (
CUSTOMIZATION_GLOBAL_SCOPE,
get_customization_prefs,
get_table,
list_custom_overrides,
set_custom_override,
set_customization_pref,
)
from devplacepy.utils import clear_user_cache
def _user_customization_toggle(username):
return get_table("users").find_one(username=username)
def _seed_global_css(uid, sentinel):
set_custom_override(
"user", uid, CUSTOMIZATION_GLOBAL_SCOPE, "css", f".{sentinel} {{ color: red; }}"
)
def _profile(page, username):
page.goto(f"{BASE_URL}/profile/{username}", wait_until="domcontentloaded")
def _click_toggle(page, category):
page.locator(f"[data-customization-toggle='{category}']").click()
page.locator(".dialog-overlay.visible .dialog-confirm").click()
def test_owner_sees_toggles_and_initial_state(bob):
page, user = bob
uid = _user_customization_toggle(user["username"])["uid"]
set_customization_pref(uid, "global", disabled=False)
set_customization_pref(uid, "pagetype", disabled=False)
_seed_global_css(uid, "owner-sees-toggle")
_profile(page, user["username"])
expect(page.locator("[data-customization]")).to_be_visible()
expect(page.locator("[data-customization-toggle='global']")).to_have_text("Disable")
expect(page.locator("[data-customization-toggle='pagetype']")).to_have_text(
"Disable"
)
def test_disable_keeps_rows(bob):
page, user = bob
uid = _user_customization_toggle(user["username"])["uid"]
set_customization_pref(uid, "global", disabled=False)
_seed_global_css(uid, "keep-rows")
_profile(page, user["username"])
_click_toggle(page, "global")
expect(page.locator("[data-customization-toggle='global']")).to_have_text("Enable")
rows = [
r
for r in list_custom_overrides("user", uid)
if r["scope"] == CUSTOMIZATION_GLOBAL_SCOPE
]
assert rows, "disabling must not delete the stored override row"
def test_admin_operates_on_other_user(alice, seeded_db):
admin_page, admin_user = alice
target = _user_customization_toggle(seeded_db["bob"]["username"])
admin_uid = _user_customization_toggle(admin_user["username"])["uid"]
get_table("users").update({"uid": admin_uid, "role": "Admin"}, ["uid"])
clear_user_cache(admin_uid)
set_customization_pref(target["uid"], "global", disabled=False)
_profile(admin_page, target["username"])
expect(admin_page.locator("[data-customization]")).to_be_visible()
_click_toggle(admin_page, "global")
expect(
admin_page.locator("[data-customization-toggle='global']")
).to_have_text("Enable")
assert get_customization_prefs("user", target["uid"])["disable_global"] is True
View File
+19
View File
@@ -0,0 +1,19 @@
# retoor <retoor@molodetz.nl>
from tests.conftest import BASE_URL
def test_devii_adopt_redirects(page, app_server):
resp = page.request.get(
f"{BASE_URL}/devii/adopt?next=/feed", max_redirects=0
)
assert resp.status == 303
assert resp.headers.get("location") == "/feed"
def test_devii_adopt_rejects_external(page, app_server):
resp = page.request.get(
f"{BASE_URL}/devii/adopt?next=https://evil.com", max_redirects=0
)
assert resp.status == 303
assert resp.headers.get("location") == "/"
View File
+11
View File
@@ -0,0 +1,11 @@
# retoor <retoor@molodetz.nl>
from tests.conftest import BASE_URL
def test_devii_clippy_unavailable(alice):
page, _ = alice
resp = page.request.post(f"{BASE_URL}/devii/clippy/ai/chat", data="{}")
assert resp.status == 503
data = resp.json()
assert "unavailable" in data.get("error", "").lower()
+8
View File
@@ -0,0 +1,8 @@
# retoor <retoor@molodetz.nl>
from tests.conftest import BASE_URL
def test_devii_page_loads(page, app_server):
page.goto(f"{BASE_URL}/devii/", wait_until="domcontentloaded")
assert "Devii" in page.title()
+65
View File
@@ -0,0 +1,65 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timezone
from types import SimpleNamespace
from tests.conftest import BASE_URL
from devplacepy.database import get_table, set_setting
from devplacepy.utils import generate_uid
from devplacepy.services.devii.config import (
effective_daily_limit,
FIELD_USER_DAILY_USD,
FIELD_GUEST_DAILY_USD,
FIELD_ADMIN_DAILY_USD,
)
from devplacepy.services.devii.store import UsageLedger
LEDGER = "devii_usage_ledger"
def _now_iso_devii_quota():
return datetime.now(timezone.utc).isoformat()
def _seed_ledger(owner_kind, owner_id, cost, n=1):
table = get_table(LEDGER)
for _ in range(n):
table.insert(
{
"owner_kind": owner_kind,
"owner_id": owner_id,
"created_at": _now_iso_devii_quota(),
"cost_usd": cost,
}
)
def _ensure_devii():
from devplacepy.services.manager import service_manager
from devplacepy.services.devii import DeviiService
if service_manager.get_service("devii") is None:
service_manager.register(DeviiService())
return service_manager.get_service("devii")
def _make_user_devii_quota(role="Member"):
uid = generate_uid()
username = f"quota_{uid[-12:]}"
get_table("users").insert(
{
"uid": uid,
"username": username,
"email": f"{username}@t.dev",
"api_key": generate_uid(),
"role": role,
"is_active": True,
}
)
return uid, username
def test_admin_reset_user_quota_endpoint(alice):
page, _ = alice
bob = get_table("users").find_one(username="bob_test")
get_table(LEDGER).insert(
{
"owner_kind": "user",
"owner_id": bob["uid"],
"created_at": _now_iso_devii_quota(),
"cost_usd": 0.5,
}
)
resp = page.request.post(f"{BASE_URL}/admin/users/{bob['uid']}/reset-ai-quota")
assert resp.ok
assert get_table(LEDGER).count(owner_kind="user", owner_id=bob["uid"]) == 0
+22
View File
@@ -0,0 +1,22 @@
# retoor <retoor@molodetz.nl>
from tests.conftest import BASE_URL
def test_devii_session_as_guest(page, app_server):
resp = page.request.get(f"{BASE_URL}/devii/session")
data = resp.json()
assert data["loggedIn"] is False
assert data["username"] == "guest"
assert "enabled" in data
assert "guestsEnabled" in data
assert data["baseUrl"] == BASE_URL
def test_devii_session_as_logged_in(alice):
page, _ = alice
resp = page.request.get(f"{BASE_URL}/devii/session")
data = resp.json()
assert data["loggedIn"] is True
assert data["username"] == "alice_test"
assert "enabled" in data
+19
View File
@@ -0,0 +1,19 @@
# retoor <retoor@molodetz.nl>
from tests.conftest import BASE_URL
def test_devii_usage_as_guest(page, app_server):
resp = page.request.get(f"{BASE_URL}/devii/usage")
data = resp.json()
assert "used_pct" in data
assert "turns_today" in data
assert data["turns_today"] == 0
def test_devii_usage_as_logged_in(alice):
page, _ = alice
resp = page.request.get(f"{BASE_URL}/devii/usage")
data = resp.json()
assert "used_pct" in data
assert "turns_today" in data
+164
View File
@@ -0,0 +1,164 @@
# retoor <retoor@molodetz.nl>
import html
import json
import re
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.docs_api import API_GROUPS
from devplacepy.utils import clear_user_cache
def _configs_by_id(text):
configs = re.findall(r"data-config='(.*?)'", text, re.S)
return {
json.loads(html.unescape(cfg))["id"]: json.loads(html.unescape(cfg))
for cfg in configs
}
def _key_role_visibility(username):
return get_table("users").find_one(username=username)["api_key"]
_author_key = []
def _author():
# A dedicated Member author so seeding posts never pollutes alice/bob state
# (notifications, post counts, XP) that other test files assert on.
if _author_key:
return _author_key[0]
name = "rolevis_author"
requests.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
row = get_table("users").find_one(username=name)
get_table("users").update({"uid": row["uid"], "role": "Member"}, ["uid"])
_author_key.append(row["api_key"])
return _author_key[0]
def _seed_post_role_visibility():
requests.post(
f"{BASE_URL}/posts/create",
headers={"X-API-KEY": _author()},
data={
"content": "role visibility seed post body text",
"title": "RoleVis",
"topic": "random",
},
)
def test_docs_layout_has_sidebar(page, app_server):
page.goto(f"{BASE_URL}/docs/index.html", wait_until="domcontentloaded")
assert (
page.locator(
".sidebar-card a.sidebar-link[href='/docs/authentication.html']"
).count()
== 1
)
def test_docs_auth_examples_use_logged_in_user(alice):
page, user = alice
page.goto(f"{BASE_URL}/docs/authentication.html", wait_until="domcontentloaded")
content = page.content()
key = get_table("users").find_one(username=user["username"])["api_key"]
assert key in content
assert user["username"] in content
def test_docs_widget_code_has_highlight_linenumbers_copy(page, app_server):
page.goto(f"{BASE_URL}/docs/social-actions.html", wait_until="domcontentloaded")
panel = page.locator(".code-block pre.code-panel").first
panel.wait_for(state="visible")
panel.locator(".code-gutter").first.wait_for(state="attached")
assert panel.locator(".code-gutter").count() >= 1
assert panel.locator(".code-copy-btn").count() >= 1
assert panel.locator("code.hljs").count() >= 1
# switching language tab re-highlights (not left as plain text)
page.locator(".code-block .code-tab:has-text('Python')").first.click()
panel.locator("code.hljs").first.wait_for(state="attached")
assert panel.locator("code.hljs").count() >= 1
def test_docs_prose_code_has_linenumbers_copy(page, app_server):
page.goto(f"{BASE_URL}/docs/authentication.html", wait_until="domcontentloaded")
pre = page.locator(".docs-content pre.code-pre").first
pre.wait_for(state="visible")
assert pre.locator(".code-gutter").count() >= 1
assert pre.locator(".code-copy-btn").count() >= 1
assert pre.locator("code.hljs").count() >= 1
def test_docs_panel_format_picker_defaults_json(page, app_server):
page.goto(f"{BASE_URL}/docs/content.html", wait_until="domcontentloaded")
panel = page.locator(".api-tester").first
panel.wait_for(state="visible")
active = panel.locator(".format-option.active").first
active.wait_for(state="visible")
assert active.inner_text().strip() == "JSON"
assert panel.locator(".response-tabs .code-tab:has-text('Expected')").count() == 1
assert (
panel.locator(".response-tabs .code-tab:has-text('Live response')").count() == 1
)
expected = panel.locator(".response-pane.active").first
expected.wait_for(state="visible")
assert expected.locator("pre.response-body code").count() >= 1
def test_docs_panel_html_format_updates_snippet(page, app_server):
page.goto(f"{BASE_URL}/docs/content.html", wait_until="domcontentloaded")
panel = page.locator(".api-tester").first
panel.wait_for(state="visible")
code = panel.locator("pre.code-panel code").first
code.wait_for(state="attached")
assert "application/json" in code.inner_text()
panel.locator(".format-option:has-text('HTML')").first.click()
panel.locator("pre.code-panel code").filter(has_text="text/html").first.wait_for(
timeout=5000
)
def test_docs_panel_live_send_returns_json(page, app_server):
page.goto(f"{BASE_URL}/docs/bugs.html", wait_until="domcontentloaded")
panel = page.locator(".api-tester").first
panel.wait_for(state="visible")
send = panel.locator(".try-send").first
send.wait_for(state="visible")
send.click()
status = panel.locator(".response-pane.active .response-status").first
status.wait_for(state="visible", timeout=10000)
assert status.inner_text().strip().startswith("2")
def test_guest_docs_hide_admin(page, app_server):
page.goto(f"{BASE_URL}/docs/index.html", wait_until="domcontentloaded")
assert page.locator("a[href='/docs/admin.html']").count() == 0
assert page.locator("a[href='/docs/services.html']").count() == 0
assert requests.get(f"{BASE_URL}/docs/admin.html").status_code == 404
assert requests.get(f"{BASE_URL}/docs/services.html").status_code == 404
def test_member_docs_hide_admin(bob):
page, _ = bob
assert (
requests.get(
f"{BASE_URL}/docs/admin.html", headers={"X-API-KEY": _key_role_visibility("bob_test")}
).status_code
== 404
)
page.goto(f"{BASE_URL}/docs/index.html", wait_until="domcontentloaded")
assert page.locator("a[href='/docs/admin.html']").count() == 0
def test_admin_docs_access(alice):
page, _ = alice
assert (
requests.get(
f"{BASE_URL}/docs/admin.html", headers={"X-API-KEY": _key_role_visibility("alice_test")}
).status_code
== 200
)
page.goto(f"{BASE_URL}/docs/index.html", wait_until="domcontentloaded")
assert page.locator("a[href='/docs/admin.html']").count() >= 1
View File
+39
View File
@@ -0,0 +1,39 @@
# retoor <retoor@molodetz.nl>
import re
from playwright.sync_api import expect
from tests.conftest import BASE_URL
def _open_composer(page):
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
page.locator(".feed-fab").first.click()
def _create_plain_post_engagement_ui(page, content):
_open_composer(page)
page.fill("#post-content", content)
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
def test_poll_builder_hidden_until_add_poll(alice):
page, _ = alice
_open_composer(page)
expect(page.locator("[data-poll-builder]")).to_be_hidden()
page.locator("[data-poll-toggle]").click()
expect(page.locator("[data-poll-builder]")).to_be_visible()
def test_reaction_palette_toggle_and_react(alice):
page, _ = alice
_create_plain_post_engagement_ui(page, "Post to react to in the reaction UI test.")
expect(page.locator(".reaction-chip:visible")).to_have_count(0)
expect(page.locator(".reaction-palette").first).to_be_hidden()
page.locator(".reaction-add-btn").first.click()
expect(page.locator(".reaction-palette").first).to_be_visible()
page.locator(".reaction-palette-btn").first.click()
expect(page.locator(".reaction-chip.reacted").first).to_be_visible()
expect(
page.locator(".reaction-chip.reacted").first.locator(".reaction-count")
).to_have_text("1")
+837
View File
@@ -0,0 +1,837 @@
# retoor <retoor@molodetz.nl>
from tests.conftest import BASE_URL
import time
import requests
from playwright.sync_api import expect
from devplacepy.database import (
CUSTOMIZATION_GLOBAL_SCOPE,
get_customization_prefs,
get_table,
list_custom_overrides,
set_custom_override,
set_customization_pref,
)
from devplacepy.utils import clear_user_cache
def _user_customization_toggle(username):
return get_table("users").find_one(username=username)
def _seed_global_css(uid, sentinel):
set_custom_override(
"user", uid, CUSTOMIZATION_GLOBAL_SCOPE, "css", f".{sentinel} {{ color: red; }}"
)
def _profile(page, username):
page.goto(f"{BASE_URL}/profile/{username}", wait_until="domcontentloaded")
def _click_toggle(page, category):
page.locator(f"[data-customization-toggle='{category}']").click()
page.locator(".dialog-overlay.visible .dialog-confirm").click()
import re
from uuid import uuid4
from datetime import datetime, timedelta, timezone
from tests.conftest import BASE_URL, assert_share_copies
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
def _seed_posts(count):
suite = uuid4().hex[:8]
topic = f"pag{suite}"
users = get_table("users")
posts = get_table("posts")
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
for i in range(count):
owner = str(uuid4())
users.insert(
{
"uid": owner,
"username": f"pag_{suite}_{i}",
"email": f"{suite}_{i}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
uid = str(uuid4())
content = f"Paginated post {i}"
posts.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(content, uid),
"title": None,
"content": content,
"topic": topic,
"project_uid": None,
"image": None,
"stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
"deleted_at": None,
"deleted_by": None,
}
)
return topic
def _seed_post_with_comments(comment_texts, topic="devlog"):
owner = str(uuid4())
get_table("users").insert(
{
"uid": owner,
"username": f"seed_{owner[:8]}",
"email": f"{owner[:8]}@seed.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
post_uid = str(uuid4())
marker = f"seedpost-{post_uid[:8]}"
get_table("posts").insert(
{
"uid": post_uid,
"user_uid": owner,
"slug": make_combined_slug(marker, post_uid),
"title": None,
"content": marker,
"topic": topic,
"project_uid": None,
"image": None,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
comments = get_table("comments")
base = datetime.now(timezone.utc)
for i, text in enumerate(comment_texts):
comments.insert(
{
"uid": str(uuid4()),
"target_type": "post",
"target_uid": post_uid,
"post_uid": post_uid,
"user_uid": owner,
"content": text,
"parent_uid": None,
"created_at": (base + timedelta(seconds=i)).isoformat(),
}
)
return marker, post_uid
def _create_post_ui(page, content, topic="devlog"):
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
page.locator(".feed-fab").first.click()
page.check(f"input[value='{topic}']")
page.fill("#post-content", content)
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
def _seed_searchable_posts():
token = uuid4().hex[:10]
users = get_table("users")
posts = get_table("posts")
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
match_title = f"Findable post {token}"
other_title = f"Unrelated post {uuid4().hex[:10]}"
for offset, title in ((0, match_title), (1, other_title)):
owner = str(uuid4())
users.insert(
{
"uid": owner,
"username": f"srch_{owner[:8]}",
"email": f"{owner[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
uid = str(uuid4())
posts.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(title, uid),
"title": title,
"content": f"Body for {title}",
"topic": "devlog",
"project_uid": None,
"image": None,
"stars": 0,
"created_at": (base - timedelta(seconds=offset)).isoformat(),
"deleted_at": None,
"deleted_by": None,
}
)
return token, match_title, other_title
from devplacepy.utils import generate_uid
DEFAULT_MAINTENANCE_MESSAGE = (
"DevPlace is undergoing scheduled maintenance. Please check back shortly."
)
OPERATIONAL_FIELDS = (
"rate_limit_per_minute",
"rate_limit_window_seconds",
"session_max_age_days",
"session_remember_days",
"registration_open",
"maintenance_mode",
"maintenance_message",
)
def _save_settings(page, **fields):
page.goto(f"{BASE_URL}/admin/settings", wait_until="domcontentloaded")
for name, value in fields.items():
locator = page.locator(f"#{name}")
tag = locator.evaluate("el => el.tagName.toLowerCase()")
if tag == "select":
page.select_option(f"#{name}", value)
else:
locator.fill(value)
page.click("button:has-text('Save Settings')")
page.wait_for_url("**/admin/settings", wait_until="domcontentloaded")
def create_post(page, topic="random", content="Test post content", title=None):
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
page.locator(".feed-fab").first.click()
page.check(f"input[value='{topic}']")
page.fill("#post-content", content)
if title:
page.fill("#post-title", title)
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
def _profile_stars(page, username):
page.goto(f"{BASE_URL}/profile/{username}", wait_until="domcontentloaded")
value = page.locator(
".profile-stat:has(.profile-stat-label:has-text('Stars')) .profile-stat-value"
).first
return int(value.text_content().strip())
def _key_role_visibility(username):
return get_table("users").find_one(username=username)["api_key"]
_author_key = []
def _author():
# A dedicated Member author so seeding posts never pollutes alice/bob state
# (notifications, post counts, XP) that other test files assert on.
if _author_key:
return _author_key[0]
name = "rolevis_author"
requests.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
row = get_table("users").find_one(username=name)
get_table("users").update({"uid": row["uid"], "role": "Member"}, ["uid"])
_author_key.append(row["api_key"])
return _author_key[0]
def _seed_post_role_visibility():
requests.post(
f"{BASE_URL}/posts/create",
headers={"X-API-KEY": _author()},
data={
"content": "role visibility seed post body text",
"title": "RoleVis",
"topic": "random",
},
)
def _seed_news_seo():
from datetime import datetime, timezone
from devplacepy.database import get_table
from devplacepy.utils import generate_uid, make_combined_slug
uid = generate_uid()
title = f"SEO Test News Article {uid.split('-')[-1]}"
slug = make_combined_slug(title, uid)
get_table("news").insert(
{
"uid": uid,
"slug": slug,
"title": title,
"description": "A seeded news article for SEO tests.",
"content": "Body content for the seeded article.",
"url": "https://example.com/article",
"source_name": "ExampleSource",
"status": "published",
"synced_at": datetime.now(timezone.utc).isoformat(),
"show_on_landing": 0,
"grade": 8,
}
)
return slug, uid
def _seed_owner():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
uid = str(uuid4())
get_table("users").insert(
{
"uid": uid,
"username": f"seo_{uid[:8]}",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def _seed_post_seo(image=None):
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Post", uid)
get_table("posts").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Post",
"content": "Body text for the SEO detail post.",
"topic": "general",
"project_uid": None,
"image": image,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_gist():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Gist", uid)
get_table("gists").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Gist",
"description": "Gist description for SEO tests.",
"source_code": "print('seo')",
"language": "python",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_project():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Project", uid)
get_table("projects").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Project",
"description": "Project description for SEO tests.",
"project_type": "software",
"platforms": "Linux",
"status": "Released",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_news_image(news_uid):
from devplacepy.database import get_table
get_table("news_images").insert(
{
"news_uid": news_uid,
"url": "https://example.com/seo-news-image.jpg",
}
)
def _seed_feed_posts(count):
from datetime import datetime, timezone, timedelta
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
topic = f"seopag{owner[:8]}"
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
posts = get_table("posts")
for i in range(count):
uid = str(uuid4())
posts.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(f"seo pag {i}", uid),
"title": None,
"content": f"seo pag post {i}",
"topic": topic,
"project_uid": None,
"image": None,
"stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
}
)
return topic
def test_bugs_footer_link(alice):
page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
link = page.locator("a:has-text('Bug Report')")
assert link.is_visible()
link.click()
page.wait_for_url(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
def test_global_toggle_suppresses_rendering_and_persists(bob):
page, user = bob
uid = _user_customization_toggle(user["username"])["uid"]
set_customization_pref(uid, "global", disabled=False)
sentinel = f"sentinel-{int(time.time() * 1000)}"
_seed_global_css(uid, sentinel)
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
assert sentinel in page.content()
_profile(page, user["username"])
_click_toggle(page, "global")
expect(page.locator("[data-customization-toggle='global']")).to_have_text("Enable")
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
assert sentinel not in page.content()
_profile(page, user["username"])
expect(page.locator("[data-customization-toggle='global']")).to_have_text("Enable")
expect(
page.locator("form[action$='/customization/global'] input[name='value']")
).to_have_value("1")
_click_toggle(page, "global")
expect(page.locator("[data-customization-toggle='global']")).to_have_text("Disable")
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
assert sentinel in page.content()
def test_pagetype_toggle_is_independent(bob):
page, user = bob
uid = _user_customization_toggle(user["username"])["uid"]
set_customization_pref(uid, "global", disabled=False)
set_customization_pref(uid, "pagetype", disabled=False)
global_sentinel = f"glob-{int(time.time() * 1000)}"
page_sentinel = f"page-{int(time.time() * 1000)}"
_seed_global_css(uid, global_sentinel)
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page_type = page.locator("main[data-page-type]").get_attribute("data-page-type")
set_custom_override(
"user", uid, page_type, "css", f".{page_sentinel} {{ color: blue; }}"
)
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
body = page.content()
assert global_sentinel in body
assert page_sentinel in body
_profile(page, user["username"])
_click_toggle(page, "pagetype")
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
body = page.content()
assert global_sentinel in body
assert page_sentinel not in body
def test_feed_page_loads(alice):
page, user = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
assert page.is_visible("text=Topics")
assert page.is_visible("text=All")
assert page.is_visible("text=Devlog")
assert page.is_visible("text=Showcase")
def test_feed_nav_tabs(alice):
page, user = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
assert page.is_visible("a:has-text('All')")
assert page.is_visible("a:has-text('Trending')")
assert page.is_visible("a:has-text('Recent')")
assert page.is_visible("a:has-text('Following')")
def test_feed_tab_switching(alice):
page, user = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.click("a:has-text('Trending')")
page.wait_for_url(f"{BASE_URL}/feed?tab=trending", wait_until="domcontentloaded")
page.click("a:has-text('Recent')")
page.wait_for_url(f"{BASE_URL}/feed?tab=recent", wait_until="domcontentloaded")
def test_feed_topic_filter_sidebar(alice):
page, user = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
topics = ["Devlog", "Showcase", "Question", "Rant", "Fun"]
for topic in topics:
link = page.locator(f"a:has-text('{topic}')").first
assert link.is_visible()
def test_feed_topic_filter_navigation(alice):
page, user = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.click("a:has-text('Devlog')")
page.wait_for_url(f"{BASE_URL}/feed?topic=devlog", wait_until="domcontentloaded")
def test_feed_search_bar_visible(alice):
page, user = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
search = page.locator("input[name='search'][placeholder='Search posts...']")
assert search.is_visible()
def test_feed_search_filters(alice):
page, user = alice
token, match_title, other_title = _seed_searchable_posts()
page.goto(f"{BASE_URL}/feed?search={token}", wait_until="domcontentloaded")
assert page.locator(f".post-title:has-text('{match_title}')").count() == 1
assert page.locator(f".post-title:has-text('{other_title}')").count() == 0
def test_feed_search_no_match(alice):
page, user = alice
_seed_searchable_posts()
page.goto(
f"{BASE_URL}/feed?search=zzz{uuid4().hex}", wait_until="domcontentloaded"
)
assert page.is_visible(".empty-state")
assert page.locator(".post-title").count() == 0
def test_feed_search_preserves_topic(alice):
page, user = alice
token, match_title, _ = _seed_searchable_posts()
page.goto(
f"{BASE_URL}/feed?topic=devlog&search={token}", wait_until="domcontentloaded"
)
assert page.locator(f".post-title:has-text('{match_title}')").count() == 1
preserved = page.locator(
".sidebar-card form input[type='hidden'][name='topic'][value='devlog']"
)
assert preserved.count() == 1
def test_feed_community_stats(alice):
page, user = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
assert page.is_visible("text=Total Members")
assert page.is_visible("text=Posts Today")
assert page.is_visible("text=Total Projects")
def test_feed_top_authors(alice):
page, user = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
assert page.is_visible("text=Top Authors")
def test_feed_daily_topic(alice):
page, user = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
assert page.is_visible("text=Daily Topic")
def test_create_post_fab(alice):
page, user = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
fab = page.locator(".feed-fab")
assert fab.is_visible()
fab.click()
assert page.is_visible("text=Create New Post")
assert page.is_visible("#post-content")
assert page.is_visible("#create-post-modal button:has-text('Post')")
def test_topnav_notification_bell(alice):
page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
bell = page.locator(".topnav-icon[href='/notifications']")
assert bell.is_visible()
def test_topnav_user_menu(alice):
page, user = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
assert page.is_visible(f"text={user['username']}")
def test_feed_inline_comment_placeholder(alice):
page, _ = alice
marker = f"placeholder-{uuid4().hex[:8]}"
_create_post_ui(page, f"{marker} placeholder check post")
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
card = page.locator(".post-card").filter(has_text=marker).first
inline_input = card.locator(".comment-form textarea[name='content']")
expect(inline_input).to_be_visible()
assert inline_input.get_attribute("placeholder") == "Your opinion goes here..."
def test_feed_shows_last_three_comments(page, app_server):
m = uuid4().hex[:6]
marker, _ = _seed_post_with_comments(
[f"{m}-one", f"{m}-two", f"{m}-three", f"{m}-four"]
)
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
card = page.locator(".post-card").filter(has_text=marker).first
card.wait_for(state="visible")
previews = card.locator(".post-card-comments .comment-text")
expect(previews).to_have_count(3)
expect(previews.nth(0)).to_contain_text(f"{m}-two")
expect(previews.nth(1)).to_contain_text(f"{m}-three")
expect(previews.nth(2)).to_contain_text(f"{m}-four")
expect(card.locator(".post-card-comments")).not_to_contain_text(f"{m}-one")
def test_feed_guest_vote_disabled(page, app_server):
marker, _ = _seed_post_with_comments([f"{uuid4().hex[:6]}-c"])
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
card = page.locator(".post-card").filter(has_text=marker).first
card.wait_for(state="visible")
assert card.locator(".post-action-btn.vote-up").first.is_disabled()
def test_feed_guest_reply_disabled(page, app_server):
marker, _ = _seed_post_with_comments([f"{uuid4().hex[:6]}-c"])
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
card = page.locator(".post-card").filter(has_text=marker).first
card.wait_for(state="visible")
assert card.locator(".post-card-comments [data-action='reply']").first.is_disabled()
def test_feed_signals_topic(alice):
page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
signals_link = page.locator("a:has-text('Signals')")
assert signals_link.is_visible()
def test_create_post_cancel_modal(alice):
page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").click()
cancel = page.locator("#create-post-modal button:has-text('Cancel')").first
cancel.click()
modal = page.locator("#create-post-modal")
assert not modal.is_visible()
def test_feed_pagination_first_page(alice):
page, _ = alice
topic = _seed_posts(26)
page.goto(f"{BASE_URL}/feed?topic={topic}", wait_until="domcontentloaded")
assert page.locator(".post-card").count() == 25
assert page.is_visible(".load-more-wrap")
def test_feed_pagination_load_more(alice):
page, _ = alice
topic = _seed_posts(26)
page.goto(f"{BASE_URL}/feed?topic={topic}", wait_until="domcontentloaded")
page.click(".load-more-wrap a")
page.wait_for_url(lambda url: "before=" in url, wait_until="domcontentloaded")
assert f"topic={topic}" in page.url
assert page.locator(".post-card").count() == 1
assert not page.is_visible(".load-more-wrap")
def test_feed_public_access(page, app_server):
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
assert page.is_visible("text=Topics")
assert page.is_visible("text=All")
assert page.is_visible("text=Login")
def test_feed_guest_fab_links_to_login(page, app_server):
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
fab = page.locator(".feed-fab")
assert fab.count() == 1
assert "/auth/login" in (fab.first.get_attribute("href") or "")
def test_feed_guest_inline_comment_disabled(page, app_server):
_seed_post_with_comments([f"{uuid4().hex[:6]}-c"])
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
assert page.locator(".post-card").count() > 0
form = page.locator(".comment-form.comment-form-guest").first
form.wait_for(state="attached")
assert form.locator("textarea").first.is_disabled()
def test_leaderboard_nav_link(alice):
page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
assert page.is_visible("a.topnav-link:has-text('Leaderboard')")
def test_mobile_hamburger_nav(alice):
page, _ = alice
page.set_viewport_size({"width": 390, "height": 844})
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
btn = page.locator("#hamburger-btn")
btn.wait_for(state="visible")
btn.click()
page.locator("#mobile-panel.open").wait_for(state="visible")
assert "open" in (page.locator("#mobile-panel").get_attribute("class") or "")
def test_notifications_bell_visible(alice):
page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
bell = page.locator(".topnav-icon[href='/notifications']")
assert bell.is_visible()
def test_maintenance_mode_blocks_guests(alice):
page, _ = alice
try:
_save_settings(page, maintenance_mode="1", maintenance_message="Down for tests")
response = requests.get(f"{BASE_URL}/feed")
assert response.status_code == 503
assert "Down for tests" in response.text
finally:
_save_settings(
page, maintenance_mode="0", maintenance_message=DEFAULT_MAINTENANCE_MESSAGE
)
def test_back_to_feed_link(alice):
page, _ = alice
create_post(page, "random", "Back link test")
back = page.locator("a:has-text('Back to Feed')")
assert back.is_visible()
back.click()
page.wait_for_url(f"{BASE_URL}/feed", wait_until="domcontentloaded")
def test_delete_own_post(alice):
page, _ = alice
create_post(page, "random", "Post to be deleted permanently here")
post_url = page.url
page.locator(".post-action-btn:has-text('Delete')").click()
page.locator(".dialog-overlay.visible .dialog-confirm").click()
page.wait_for_url(f"{BASE_URL}/feed", wait_until="domcontentloaded")
resp = page.goto(post_url, wait_until="domcontentloaded")
assert resp.status == 404
def test_profile_nav_from_topbar(alice):
page, user = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".topnav-user").click()
profile_link = page.locator(".dropdown-menu a:has-text('Profile')")
profile_link.wait_for(state="visible")
profile_link.click()
page.wait_for_url(
f"**/profile/{user['username']}", wait_until="domcontentloaded"
)
assert page.is_visible(f"text={user['username']}")
def test_guest_has_no_admin_nav(page, app_server):
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
assert page.locator("a[href='/admin']").count() == 0
assert page.locator("a[href='/auth/login']").count() >= 1
def test_guest_sees_no_role_badges(page, seeded_db):
_seed_post_role_visibility()
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".post-card").first.wait_for(state="visible")
assert page.locator(".post-author-role").count() == 0
def test_guest_action_controls_disabled(page, seeded_db):
_seed_post_role_visibility()
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
card = page.locator(".post-card").first
card.wait_for(state="visible")
assert card.locator(".vote-up").first.is_disabled()
fab = page.locator(".feed-fab").first
assert "/auth/login" in (fab.get_attribute("href") or "")
def test_member_has_no_admin_nav(bob):
page, _ = bob
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
assert page.locator("a[href='/admin']").count() == 0
def test_member_sees_no_role_badges(bob):
page, _ = bob
_seed_post_role_visibility()
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".post-card").first.wait_for(state="visible")
assert page.locator(".post-author-role").count() == 0
def test_member_can_act(bob):
page, _ = bob
_seed_post_role_visibility()
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
fab = page.locator(".feed-fab").first
fab.wait_for(state="visible")
assert (fab.get_attribute("data-modal") or "") == "create-post-modal"
card = page.locator(".post-card").first
card.wait_for(state="visible")
assert not card.locator(".vote-up").first.is_disabled()
def test_admin_has_admin_nav(alice):
page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
assert page.locator("a[href='/admin']").count() >= 1
def test_admin_sees_role_badges(alice):
page, _ = alice
_seed_post_role_visibility()
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".post-card").first.wait_for(state="visible")
assert page.locator(".post-author-role").count() >= 1
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_feed_pagination_emits_rel_next(page, app_server):
topic = _seed_feed_posts(26)
page.goto(f"{BASE_URL}/feed?topic={topic}", wait_until="domcontentloaded")
nxt = page.locator('link[rel="next"]')
assert nxt.count() == 1
href = nxt.get_attribute("href")
assert "before=" in href and f"topic={topic}" in href, href
+99
View File
@@ -0,0 +1,99 @@
# retoor <retoor@molodetz.nl>
import json
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.services.jobs import queue
def _seed_job(uid="test-fork-uid-001"):
jobs = get_table("jobs")
jobs.upsert(
{
"uid": uid,
"kind": "fork",
"status": "done",
"payload": json.dumps(
{"source_project_uid": "src-001", "title": "Test Fork"}
),
"result": json.dumps(
{
"project_uid": "forked-project-001",
"project_url": "/projects/forked-project-001",
"source_project_uid": "src-001",
}
),
"preferred_name": "Test Fork",
"item_count": 3,
"owner_kind": "user",
"owner_uid": "test-owner-001",
"error": None,
"created_at": "2026-06-12T00:00:00+00:00",
"completed_at": "2026-06-12T00:05:00+00:00",
},
["uid"],
)
return uid
def test_fork_status_returns_job_details(page, app_server):
uid = _seed_job()
resp = page.request.get(f"{BASE_URL}/forks/{uid}")
assert resp.status == 200
data = resp.json()
assert data["uid"] == uid
assert data["kind"] == "fork"
assert data["status"] == "done"
assert data["project_uid"] == "forked-project-001"
assert data["project_url"] == "/projects/forked-project-001"
assert data["source_project_uid"] == "src-001"
assert data["item_count"] == 3
assert data["preferred_name"] == "Test Fork"
assert data["error"] is None
assert data["created_at"] is not None
assert data["completed_at"] is not None
def test_fork_status_unknown_returns_404(page, app_server):
resp = page.request.get(f"{BASE_URL}/forks/nonexistent-uid")
assert resp.status == 404
def test_fork_status_non_fork_kind_returns_404(page, app_server):
jobs = get_table("jobs")
jobs.upsert(
{
"uid": "non-fork-uid",
"kind": "zip",
"status": "pending",
"payload": json.dumps({}),
},
["uid"],
)
resp = page.request.get(f"{BASE_URL}/forks/non-fork-uid")
assert resp.status == 404
def test_fork_status_pending_shows_no_project_fields(page, app_server):
uid = "pending-fork-001"
jobs = get_table("jobs")
jobs.upsert(
{
"uid": uid,
"kind": "fork",
"status": "pending",
"payload": json.dumps(
{"source_project_uid": "src-002", "title": "Pending Fork"}
),
"preferred_name": "Pending Fork",
"item_count": 0,
"created_at": "2026-06-12T00:00:00+00:00",
},
["uid"],
)
resp = page.request.get(f"{BASE_URL}/forks/{uid}")
assert resp.status == 200
data = resp.json()
assert data["uid"] == uid
assert data["status"] == "pending"
assert data["project_uid"] is None
assert data["project_url"] is None
assert data["completed_at"] is None
+400
View File
@@ -0,0 +1,400 @@
# retoor <retoor@molodetz.nl>
import re
import time
import requests
from uuid import uuid4
from datetime import datetime, timedelta, timezone
from playwright.sync_api import expect
from tests.conftest import BASE_URL, assert_share_copies
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
DPBOT_SIZE = 112147
SOURCE_LENGTH_LIMIT = 400000
def _make_source(length):
unit = "def handler():\n return 0\n"
return (unit * (length // len(unit) + 1))[:length]
def _register_session(prefix):
name = f"{prefix}{int(time.time() * 1000)}"
session = requests.Session()
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return session, name
def _seed_gists(count):
owner = str(uuid4())
get_table("users").insert(
{
"uid": owner,
"username": f"pag_{owner[:8]}",
"email": f"{owner[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
gists = get_table("gists")
for i in range(count):
uid = str(uuid4())
title = f"Pag Gist {i}"
gists.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(title, uid),
"title": title,
"language": "python",
"description": "paginated",
"stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
}
)
return owner
def _set_cm_value(page, value):
page.evaluate(f"""() => {{
const cm = document.querySelector(".CodeMirror")?.CodeMirror;
if (cm) {{
cm.setValue({repr(value)});
cm.save();
}}
}}""")
def _create_gist(
page,
title="Test Gist",
description="Test description",
language="python",
source_code="print('hello')",
):
page.goto(f"{BASE_URL}/gists", wait_until="domcontentloaded")
page.locator("#create-gist-btn").wait_for(state="visible", timeout=10000)
page.locator("#create-gist-btn").click()
page.wait_for_timeout(800)
page.fill("#gist-title", title)
page.fill("#gist-description", description)
page.select_option("#gist-language", language)
page.wait_for_timeout(300)
_set_cm_value(page, source_code)
page.wait_for_timeout(300)
page.locator("button.btn-primary:has-text('Create Gist')").click()
page.wait_for_timeout(2000)
current = page.url
if "/gists/" in current and current != f"{BASE_URL}/gists":
return
page.wait_for_url("**/gists/*", timeout=15000, wait_until="domcontentloaded")
def _seed_searchable_gists(language="python"):
token = uuid4().hex[:10]
owner = str(uuid4())
get_table("users").insert(
{
"uid": owner,
"username": f"gsrch_{owner[:8]}",
"email": f"{owner[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
gists = get_table("gists")
match_title = f"Findable gist {token}"
other_title = f"Unrelated gist {uuid4().hex[:10]}"
for offset, title in ((0, match_title), (1, other_title)):
uid = str(uuid4())
gists.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(title, uid),
"title": title,
"language": language,
"description": f"Description for {title}",
"source_code": "x = 1",
"stars": 0,
"created_at": (base - timedelta(seconds=offset)).isoformat(),
"deleted_at": None,
"deleted_by": None,
}
)
return token, match_title, other_title
def test_gist_listing_loads(page, app_server):
page.goto(f"{BASE_URL}/gists", wait_until="domcontentloaded")
assert page.is_visible("h1:has-text('Gists')")
assert page.is_visible("text=Share and discover code snippets")
def test_gist_listing_empty(page, app_server):
# filter to a user with no gists so the empty state is deterministic even when
# other tests (sharing the session DB) have created gists
page.goto(
f"{BASE_URL}/gists?user_uid=no-such-user-xyz", wait_until="domcontentloaded"
)
assert page.is_visible("text=No gists found")
def test_gist_listing_shows_sidebar(page, app_server):
page.goto(f"{BASE_URL}/gists", wait_until="domcontentloaded")
assert page.is_visible("text=Languages")
assert page.is_visible("text=All")
def test_guest_cannot_create(page, app_server):
page.goto(f"{BASE_URL}/gists", wait_until="domcontentloaded")
assert not page.is_visible("#create-gist-btn")
def test_create_gist(alice, app_server):
page, _ = alice
title = f"Create Test {int(time.time())}"
_create_gist(page, title=title, source_code="print('create test')")
assert page.is_visible(f"text={title}")
assert page.is_visible("text=Python")
assert page.is_visible("text=print('create test')")
def test_gist_detail_shows_all_sections(alice, app_server):
page, _ = alice
title = f"Detail Test {int(time.time())}"
_create_gist(
page,
title=title,
description="See all sections",
source_code="def hello(): pass",
)
assert page.is_visible("text=See all sections")
assert page.is_visible("text=def hello(): pass")
assert page.is_visible("text=Copy")
assert page.is_visible("text=Back to Gists")
def test_gist_detail_back_link(alice, app_server):
page, _ = alice
title = f"Back Link Test {int(time.time())}"
_create_gist(page, title=title, source_code="x = 1")
page.click("text=Back to Gists")
page.wait_for_url(f"{BASE_URL}/gists", timeout=10000, wait_until="domcontentloaded")
assert page.is_visible("h1:has-text('Gists')")
def test_edit_gist(alice, app_server):
page, _ = alice
title = f"Edit Test Original {int(time.time())}"
_create_gist(page, title=title, source_code="x = 1")
page.locator("button:has-text('Edit')").wait_for(state="visible", timeout=5000)
page.click("button:has-text('Edit')")
page.wait_for_timeout(800)
new_title = f"Edit Test Updated {int(time.time())}"
page.fill("#edit-gist-title", new_title)
page.select_option("#edit-gist-language", "javascript")
_set_cm_value(page, "const x = 1;")
page.wait_for_timeout(300)
page.locator("button.btn-primary:has-text('Save Changes')").click()
page.wait_for_url("**/gists/*", timeout=15000, wait_until="domcontentloaded")
assert page.is_visible(f"text={new_title}")
assert page.is_visible("text=JavaScript")
def test_non_owner_cannot_edit_or_delete(bob, alice, app_server):
bob_page, _ = bob
alice_page, alice_user = alice
title = f"Owner Check {int(time.time())}"
_create_gist(alice_page, title=title, source_code="owner_only = True")
slug = alice_page.url.split("/gists/")[-1]
bob_page.goto(f"{BASE_URL}/gists/{slug}", wait_until="domcontentloaded")
assert not bob_page.is_visible("button:has-text('Edit')")
assert not bob_page.is_visible("button:has-text('Delete')")
def test_delete_gist(alice, app_server):
page, _ = alice
title = f"Delete Test {int(time.time())}"
_create_gist(page, title=title, source_code="delete_me = True")
page.locator("button:has-text('Delete')").wait_for(state="visible", timeout=5000)
page.click("button:has-text('Delete')")
page.locator(".dialog-overlay.visible .dialog-confirm").click()
page.wait_for_url(f"{BASE_URL}/gists", timeout=10000, wait_until="domcontentloaded")
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("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("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_gist_code_copy_button(alice, app_server):
from playwright.sync_api import expect
page, _ = alice
_create_gist(page, title=f"Copy Gist {int(time.time())}", source_code="copyme = 42")
btn = page.locator("button[data-copy='gist-code-content']")
btn.click()
expect(btn).to_have_text("Copied!", timeout=3000)
def test_language_filter(alice, app_server):
page, _ = alice
title = f"Lang Filter Test {int(time.time())}"
_create_gist(page, title=title, language="go", source_code="package main")
page.goto(f"{BASE_URL}/gists?language=go", wait_until="domcontentloaded")
assert page.is_visible(f"text={title}")
page.goto(f"{BASE_URL}/gists?language=python", wait_until="domcontentloaded")
assert not page.is_visible(f"text={title}")
def test_gists_search_bar_visible(alice):
page, _ = alice
page.goto(f"{BASE_URL}/gists", wait_until="domcontentloaded")
search = page.locator("input[name='search'][placeholder='Search gists...']")
assert search.is_visible()
def test_gists_search_filters(alice):
page, _ = alice
token, match_title, other_title = _seed_searchable_gists()
page.goto(f"{BASE_URL}/gists?search={token}", wait_until="domcontentloaded")
assert page.locator(f".gist-card-title:has-text('{match_title}')").count() == 1
assert page.locator(f".gist-card-title:has-text('{other_title}')").count() == 0
def test_gists_search_no_match(alice):
page, _ = alice
_seed_searchable_gists()
page.goto(
f"{BASE_URL}/gists?search=zzz{uuid4().hex}", wait_until="domcontentloaded"
)
assert page.locator(".gist-card-title").count() == 0
def test_gists_search_preserves_language(alice):
page, _ = alice
token, match_title, _ = _seed_searchable_gists(language="go")
page.goto(
f"{BASE_URL}/gists?language=go&search={token}", wait_until="domcontentloaded"
)
assert page.locator(f".gist-card-title:has-text('{match_title}')").count() == 1
preserved = page.locator(
".sidebar-card form input[type='hidden'][name='language'][value='go']"
)
assert preserved.count() == 1
def test_gist_comments(alice, app_server):
page, _ = alice
title = f"Comment Test {int(time.time())}"
_create_gist(page, title=title, source_code="has_comments = True")
comment_text = f"Nice gist! {int(time.time())}"
comment_input = page.locator("textarea[name='content']").first
comment_input.wait_for(state="visible", timeout=5000)
comment_input.fill(comment_text)
page.locator("button.comment-form-submit").first.click()
page.wait_for_url("**/gists/*", timeout=10000, wait_until="domcontentloaded")
assert page.is_visible(f"text={comment_text}")
def test_gist_listing_shows_created_gist(alice, app_server):
page, _ = alice
title = f"Listing Shows {int(time.time())}"
_create_gist(page, title=title, source_code="show_in_list = True")
page.goto(f"{BASE_URL}/gists", wait_until="domcontentloaded")
assert page.is_visible(f"text={title}")
def test_gist_pagination_first_page(alice):
page, _ = alice
owner = _seed_gists(26)
page.goto(f"{BASE_URL}/gists?user_uid={owner}", wait_until="domcontentloaded")
assert page.locator(".gist-card").count() == 25
assert page.is_visible(".load-more-wrap")
def test_gist_pagination_load_more(alice):
page, _ = alice
owner = _seed_gists(26)
page.goto(f"{BASE_URL}/gists?user_uid={owner}", wait_until="domcontentloaded")
page.click(".load-more-wrap a")
page.wait_for_url(lambda url: "before=" in url, wait_until="domcontentloaded")
assert f"user_uid={owner}" in page.url
assert page.locator(".gist-card").count() == 1
assert not page.is_visible(".load-more-wrap")
def test_gist_no_pagination_below_page_size(alice):
page, _ = alice
owner = _seed_gists(10)
page.goto(f"{BASE_URL}/gists?user_uid={owner}", wait_until="domcontentloaded")
assert page.locator(".gist-card").count() == 10
assert not page.is_visible(".load-more-wrap")
def test_gist_voted_state_persists(alice):
page, _ = alice
_create_gist(page, title="Voted State Gist")
star = "form[action*='/votes/gist/'] button"
page.locator(star).first.click()
page.wait_for_timeout(500)
page.reload(wait_until="domcontentloaded")
expect(page.locator(star).first).to_have_class(re.compile(r"\bvoted\b"))
def test_oversized_gist_shows_client_error(alice, app_server):
page, _ = alice
title = f"Client Oversize {int(time.time())}"
page.goto(f"{BASE_URL}/gists", wait_until="domcontentloaded")
page.locator("#create-gist-btn").wait_for(state="visible", timeout=10000)
page.locator("#create-gist-btn").click()
page.wait_for_timeout(800)
page.fill("#gist-title", title)
page.wait_for_timeout(300)
_set_cm_value(page, _make_source(SOURCE_LENGTH_LIMIT + 1))
page.wait_for_timeout(300)
page.locator("button.btn-primary:has-text('Create Gist')").click()
error = page.locator(".gist-length-error")
error.wait_for(state="visible", timeout=5000)
assert "maximum" in error.text_content()
assert page.url.rstrip("/") == f"{BASE_URL}/gists"
assert get_table("gists").find_one(title=title) is None
def test_create_large_gist_via_ui_saves(alice, app_server):
page, _ = alice
title = f"UI Large {int(time.time())}"
source = _make_source(DPBOT_SIZE)
_create_gist(page, title=title, description="ui large", source_code=source)
assert "/gists/" in page.url
saved = get_table("gists").find_one(title=title)
assert saved is not None
assert len(saved["source_code"]) > DPBOT_SIZE // 2
+53
View File
@@ -0,0 +1,53 @@
# retoor <retoor@molodetz.nl>
from tests.conftest import BASE_URL
def test_leaderboard_page_loads(alice):
page, _ = alice
page.goto(f"{BASE_URL}/leaderboard", wait_until="domcontentloaded")
assert page.locator(".leaderboard-header h1").is_visible()
assert page.is_visible("text=Ranked by total stars")
def test_leaderboard_no_pagination(alice):
page, _ = alice
page.goto(f"{BASE_URL}/leaderboard", wait_until="domcontentloaded")
assert page.locator(".pagination").count() == 0
def test_leaderboard_ranks_after_upvote(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
try:
pa = ctx_a.new_page()
pb = ctx_b.new_page()
pa.set_default_timeout(15000)
pb.set_default_timeout(15000)
login_user(pa, seeded_db["alice"])
login_user(pb, seeded_db["bob"])
pb.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
pb.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
pb.locator(".feed-fab").first.click()
pb.fill("#post-content", "Post for leaderboard ranking test")
pb.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
pb.wait_for_url("**/posts/*", timeout=10000, wait_until="domcontentloaded")
post_url = pb.url
pa.goto(post_url, wait_until="domcontentloaded")
pa.wait_for_timeout(1000)
vote_btn = pa.locator("button.post-action-btn").filter(has_text="+").first
vote_btn.wait_for(state="visible", timeout=10000)
vote_btn.click()
pa.wait_for_timeout(1500)
pa.goto(f"{BASE_URL}/leaderboard", wait_until="domcontentloaded")
body = pa.locator("body").text_content()
assert "Internal Server Error" not in body, f"Got 500 on leaderboard: {body[:300]}"
assert pa.is_visible("a.leaderboard-name:has-text('bob_test')")
finally:
ctx_a.close()
ctx_b.close()
View File
+119
View File
@@ -0,0 +1,119 @@
# retoor <retoor@molodetz.nl>
import io
import uuid
import requests
from PIL import Image
from tests.conftest import BASE_URL
from devplacepy.database import get_table
JSON_media = {"Accept": "application/json"}
def _png_bytes_media(color=(200, 30, 30)):
buf = io.BytesIO()
Image.new("RGB", (8, 8), color).save(buf, "PNG")
return buf.getvalue()
def _signup_media(prefix="media"):
s = requests.Session()
name = f"{prefix}_{uuid.uuid4().hex[:10]}"
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@test.devplace",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _login_media(seeded_db, who):
s = requests.Session()
creds = seeded_db[who]
s.post(
f"{BASE_URL}/auth/login",
data={"email": creds["email"], "password": creds["password"]},
allow_redirects=True,
)
return s
def _upload_media(s, name="pic.png", content=None, mime="image/png"):
files = {"file": (name, content if content is not None else _png_bytes_media(), mime)}
r = s.post(f"{BASE_URL}/uploads/upload", files=files)
assert r.status_code == 201, r.text
return r.json()["uid"]
def _create_post_media(s, attachment_uids, title="media post"):
r = s.post(
f"{BASE_URL}/posts/create",
data={
"content": "A post that carries media for the gallery tests.",
"title": title,
"topic": "random",
"attachment_uids": attachment_uids,
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert "/posts/" in r.url, r.url
return r.url
def _create_project_media(s, attachment_uids, title="Media Project"):
r = s.post(
f"{BASE_URL}/projects/create",
data={
"title": title,
"description": "Project with media for gallery tests.",
"project_type": "software",
"platforms": "linux",
"status": "In Development",
"attachment_uids": attachment_uids,
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert "/projects/" in r.url, r.url
return r.url
def _media_json(session, username):
r = session.get(f"{BASE_URL}/profile/{username}?tab=media", headers=JSON_media)
assert r.status_code == 200, r.text[:300]
return r.json()
def _media_uids(session, username):
return [m["uid"] for m in _media_json(session, username)["media"]]
def _seed_media_via_browser(page, title="ui media"):
up = page.request.post(
f"{BASE_URL}/uploads/upload",
multipart={
"file": {
"name": "pic.png",
"mimeType": "image/png",
"buffer": _png_bytes_media(),
}
},
)
assert up.ok, up.text()
uid = up.json()["uid"]
post = page.request.post(
f"{BASE_URL}/posts/create",
form={
"content": "media for the ui gallery test",
"title": title,
"topic": "random",
"attachment_uids": uid,
},
)
assert post.ok, post.text()
return uid
def _media_uids_via_page(page, username):
resp = page.request.get(f"{BASE_URL}/profile/{username}?tab=media", headers=JSON_media)
return [m["uid"] for m in resp.json()["media"]]
def test_ui_admin_restore_from_trash(alice):
page, user = alice
uid = _seed_media_via_browser(page, title="restore me")
# soft delete via API (carries the admin's cookies)
page.request.post(f"{BASE_URL}/media/{uid}/delete", headers=JSON_media)
page.goto(f"{BASE_URL}/admin/media", wait_until="domcontentloaded")
tile = page.locator(f".media-tile[data-media-tile='{uid}']")
tile.wait_for(state="attached")
page.locator(
f".media-tile[data-media-tile='{uid}'] form[action='/media/{uid}/restore'] button"
).click()
page.wait_for_url("**/admin/media", wait_until="domcontentloaded")
assert uid in _media_uids_via_page(page, user["username"])
+235
View File
@@ -0,0 +1,235 @@
# retoor <retoor@molodetz.nl>
import time
from tests.conftest import BASE_URL
from devplacepy.database import get_table
import requests
def _seed_news_seo():
from datetime import datetime, timezone
from devplacepy.database import get_table
from devplacepy.utils import generate_uid, make_combined_slug
uid = generate_uid()
title = f"SEO Test News Article {uid.split('-')[-1]}"
slug = make_combined_slug(title, uid)
get_table("news").insert(
{
"uid": uid,
"slug": slug,
"title": title,
"description": "A seeded news article for SEO tests.",
"content": "Body content for the seeded article.",
"url": "https://example.com/article",
"source_name": "ExampleSource",
"status": "published",
"synced_at": datetime.now(timezone.utc).isoformat(),
"show_on_landing": 0,
"grade": 8,
}
)
return slug, uid
def _seed_owner():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
uid = str(uuid4())
get_table("users").insert(
{
"uid": uid,
"username": f"seo_{uid[:8]}",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def _seed_post_seo(image=None):
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Post", uid)
get_table("posts").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Post",
"content": "Body text for the SEO detail post.",
"topic": "general",
"project_uid": None,
"image": image,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_gist():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Gist", uid)
get_table("gists").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Gist",
"description": "Gist description for SEO tests.",
"source_code": "print('seo')",
"language": "python",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_project():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Project", uid)
get_table("projects").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Project",
"description": "Project description for SEO tests.",
"project_type": "software",
"platforms": "Linux",
"status": "Released",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_news_image(news_uid):
from devplacepy.database import get_table
get_table("news_images").insert(
{
"news_uid": news_uid,
"url": "https://example.com/seo-news-image.jpg",
}
)
def _seed_feed_posts(count):
from datetime import datetime, timezone, timedelta
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
topic = f"seopag{owner[:8]}"
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
posts = get_table("posts")
for i in range(count):
uid = str(uuid4())
posts.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(f"seo pag {i}", uid),
"title": None,
"content": f"seo pag post {i}",
"topic": topic,
"project_uid": None,
"image": None,
"stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
}
)
return topic
def test_send_message_appears_in_thread(alice):
page, _ = alice
bob = get_table("users").find_one(username="bob_test")
page.goto(
f"{BASE_URL}/messages?with_uid={bob['uid']}", wait_until="domcontentloaded"
)
msg = f"Hello bob {int(time.time() * 1000)}"
page.fill("input[name='content']", msg)
page.locator(".messages-send-btn").click()
page.wait_for_url("**/messages**", wait_until="domcontentloaded")
assert page.is_visible(f".message-bubble:has-text('{msg}')")
def test_messages_page_loads(alice):
page, _ = alice
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")
assert page.is_visible(".messages-layout")
def test_messages_search_input(alice):
page, _ = alice
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")
search = page.locator("#message-search")
assert search.is_visible()
assert search.get_attribute("placeholder") == "Search conversations..."
def test_messages_empty_state(alice):
page, _ = alice
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):
page, _ = alice
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")
page.fill("#message-search", "bob_test")
result = page.locator(".search-dropdown-item:has-text('bob_test')")
result.wait_for(state="visible", timeout=5000)
assert result.is_visible()
def test_messages_header_visible(alice):
page, _ = alice
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")
assert page.is_visible("text=Home")
assert page.is_visible("text=Projects")
def test_messages_bell_icon(alice):
page, _ = alice
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")
bell = page.locator(".topnav-icon[href='/notifications']")
assert bell.is_visible()
def test_messages_topnav_user(alice):
page, user = alice
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")
assert page.is_visible(f"text={user['username']}")
def test_messages_avatar_on_page(alice):
page, user = alice
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")
avatar = page.locator("img.avatar-img").first
assert avatar.is_visible()
def test_messages_page_is_noindex(alice):
page, user = alice
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")
page.wait_for_url("**/messages", wait_until="domcontentloaded")
meta = page.locator('meta[name="robots"]')
content = meta.get_attribute("content")
assert content and "noindex" in content
+299
View File
@@ -0,0 +1,299 @@
# retoor <retoor@molodetz.nl>
import pytest
from uuid import uuid4
from datetime import datetime, timedelta, timezone
from tests.conftest import BASE_URL, assert_share_copies
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
def _seed_news_paginated(count):
news_table = get_table("news")
base = datetime.now(timezone.utc)
marker = uuid4().hex[:8]
titles = []
for i in range(count):
uid = str(uuid4())
title = f"Pag News {marker} {i:02d}"
titles.append(title)
news_table.insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": f"pag_{marker}_{i}",
"grade": 10,
"status": "published",
"show_on_landing": 0,
"source_name": "PagSource",
"url": "https://example.com",
"description": "paginated news",
"synced_at": (base - timedelta(seconds=i)).isoformat(),
}
)
return titles
def seed_news():
news_table = get_table("news")
for i in range(3):
eid = f"test_news_{i}"
if news_table.find_one(external_id=eid):
continue
uid = str(uuid4())
title = f"News Test Article {i}"
slug = make_combined_slug(title, uid)
news_table.insert(
{
"uid": uid,
"slug": slug,
"title": title,
"external_id": eid,
"grade": 8 + i,
"status": "published",
"show_on_landing": 1,
"source_name": "TestSource",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": f"Description for test article {i}.",
}
)
@pytest.fixture
def news_article(app_server):
seed_news()
article = get_table("news").find_one(external_id="test_news_0")
return article
import requests
from tests.conftest import BASE_URL
def _seed_news_seo():
from datetime import datetime, timezone
from devplacepy.database import get_table
from devplacepy.utils import generate_uid, make_combined_slug
uid = generate_uid()
title = f"SEO Test News Article {uid.split('-')[-1]}"
slug = make_combined_slug(title, uid)
get_table("news").insert(
{
"uid": uid,
"slug": slug,
"title": title,
"description": "A seeded news article for SEO tests.",
"content": "Body content for the seeded article.",
"url": "https://example.com/article",
"source_name": "ExampleSource",
"status": "published",
"synced_at": datetime.now(timezone.utc).isoformat(),
"show_on_landing": 0,
"grade": 8,
}
)
return slug, uid
def _seed_owner():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
uid = str(uuid4())
get_table("users").insert(
{
"uid": uid,
"username": f"seo_{uid[:8]}",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def _seed_post_seo(image=None):
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Post", uid)
get_table("posts").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Post",
"content": "Body text for the SEO detail post.",
"topic": "general",
"project_uid": None,
"image": image,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_gist():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Gist", uid)
get_table("gists").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Gist",
"description": "Gist description for SEO tests.",
"source_code": "print('seo')",
"language": "python",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_project():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Project", uid)
get_table("projects").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Project",
"description": "Project description for SEO tests.",
"project_type": "software",
"platforms": "Linux",
"status": "Released",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_news_image(news_uid):
from devplacepy.database import get_table
get_table("news_images").insert(
{
"news_uid": news_uid,
"url": "https://example.com/seo-news-image.jpg",
}
)
def _seed_feed_posts(count):
from datetime import datetime, timezone, timedelta
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
topic = f"seopag{owner[:8]}"
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
posts = get_table("posts")
for i in range(count):
uid = str(uuid4())
posts.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(f"seo pag {i}", uid),
"title": None,
"content": f"seo pag post {i}",
"topic": topic,
"project_uid": None,
"image": None,
"stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
}
)
return topic
def test_news_detail_share_button(page, news_article):
slug = news_article.get("slug") or news_article["uid"]
page.goto(f"{BASE_URL}/news/{slug}", wait_until="domcontentloaded")
assert_share_copies(page, "/news/")
def test_news_listing_loads(page, app_server):
seed_news()
page.goto(f"{BASE_URL}/news", wait_until="domcontentloaded")
assert page.is_visible("text=Developer News")
assert page.is_visible("text=News Test Article")
def test_news_listing_authenticated(alice):
page, _ = alice
seed_news()
page.goto(f"{BASE_URL}/news", wait_until="domcontentloaded")
assert page.is_visible("text=Developer News")
def test_news_detail_loads(page, news_article):
slug = news_article.get("slug") or news_article["uid"]
page.goto(f"{BASE_URL}/news/{slug}", wait_until="domcontentloaded")
assert page.is_visible("text=News Test Article")
assert page.is_visible("text=Comments")
def test_news_detail_404(page, app_server):
page.goto(f"{BASE_URL}/news/nonexistent-article", wait_until="domcontentloaded")
assert page.is_visible("text=not found", timeout=5000)
def test_news_comment(alice, news_article):
page, _ = alice
slug = news_article.get("slug") or news_article["uid"]
page.goto(f"{BASE_URL}/news/{slug}", wait_until="domcontentloaded")
page.fill("textarea[name='content']", "Test comment on news article")
page.click("button:has-text('Post')")
page.wait_for_url(f"{BASE_URL}/news/{slug}**", wait_until="domcontentloaded")
assert page.is_visible("text=Test comment on news article")
def test_news_pagination_first_page(page, app_server):
_seed_news_paginated(30)
page.goto(f"{BASE_URL}/news", wait_until="domcontentloaded")
assert page.locator(".news-card").count() == 25
assert page.is_visible(".load-more-wrap")
def test_news_pagination_load_more(page, app_server):
titles = _seed_news_paginated(30)
page.goto(f"{BASE_URL}/news", wait_until="domcontentloaded")
newest = titles[0]
assert page.is_visible(f".news-card-title:has-text('{newest}')")
page.click(".load-more-wrap a")
page.wait_for_url(lambda url: "before=" in url, wait_until="domcontentloaded")
assert not page.is_visible(f".news-card-title:has-text('{newest}')")
assert page.locator(".news-card").count() >= 1
def test_news_detail_guest(page, news_article):
slug = news_article.get("slug") or news_article["uid"]
page.goto(f"{BASE_URL}/news/{slug}", wait_until="domcontentloaded")
assert page.is_visible("text=News Test Article")
assert page.is_visible("text=Comments")
assert page.is_visible("text=Read on TestSource")
def test_news_detail_has_newsarticle_schema(page, app_server):
slug, _ = _seed_news_seo()
page.goto(f"{BASE_URL}/news/{slug}", wait_until="domcontentloaded")
scripts = page.locator('script[type="application/ld+json"]')
text = " ".join(scripts.nth(i).text_content() for i in range(scripts.count()))
assert "NewsArticle" in text
assert (
page.locator('meta[property="og:type"]').get_attribute("content") == "article"
)
def test_news_detail_image_has_alt_text(page, app_server):
slug, uid = _seed_news_seo()
_seed_news_image(uid)
page.goto(f"{BASE_URL}/news/{slug}", wait_until="domcontentloaded")
alt = page.locator(".news-detail-image img").get_attribute("alt")
assert alt and alt.strip()
View File
+99
View File
@@ -0,0 +1,99 @@
# retoor <retoor@molodetz.nl>
import requests
from playwright.sync_api import expect
from tests.conftest import BASE_URL
from devplacepy.database import (
get_notification_default,
get_notification_prefs,
get_table,
notification_enabled,
reset_notification_prefs,
set_notification_default,
set_notification_pref,
)
from devplacepy.utils import create_notification, generate_uid
def _user_notification_prefs(username):
return get_table("users").find_one(username=username)
def _pref(prefs, key):
return next(p for p in prefs if p["key"] == key)
def test_defaults_all_enabled(bob):
_, user = bob
uid = _user_notification_prefs(user["username"])["uid"]
reset_notification_prefs(uid)
prefs = get_notification_prefs(uid)
assert prefs, "every notification type must be listed"
for pref in prefs:
assert pref["in_app"] is True
assert pref["push"] is True
assert pref["customized"] is False
assert notification_enabled(uid, "vote", "in_app") is True
assert notification_enabled(uid, "vote", "push") is True
def test_set_pref_channels_are_independent(bob):
_, user = bob
uid = _user_notification_prefs(user["username"])["uid"]
reset_notification_prefs(uid)
try:
set_notification_pref(uid, "vote", "push", False)
assert notification_enabled(uid, "vote", "push") is False
assert notification_enabled(uid, "vote", "in_app") is True
vote = _pref(get_notification_prefs(uid), "vote")
assert vote["push"] is False
assert vote["in_app"] is True
assert vote["customized"] is True
# an untouched type is unaffected
assert notification_enabled(uid, "follow", "push") is True
finally:
reset_notification_prefs(uid)
def test_reset_restores_defaults(bob):
_, user = bob
uid = _user_notification_prefs(user["username"])["uid"]
set_notification_pref(uid, "comment", "in_app", False)
assert notification_enabled(uid, "comment", "in_app") is False
reset_notification_prefs(uid)
assert notification_enabled(uid, "comment", "in_app") is True
assert _pref(get_notification_prefs(uid), "comment")["customized"] is False
def test_global_default_applies_only_to_untouched_users(alice, bob):
_, admin = alice
_, member = bob
admin_uid = _user_notification_prefs(admin["username"])["uid"]
member_uid = _user_notification_prefs(member["username"])["uid"]
reset_notification_prefs(admin_uid)
set_notification_pref(member_uid, "badge", "push", True)
try:
set_notification_default("badge", "push", False)
# untouched admin inherits the new default
assert notification_enabled(admin_uid, "badge", "push") is False
# member with an explicit choice keeps it
assert notification_enabled(member_uid, "badge", "push") is True
finally:
set_notification_default("badge", "push", True)
reset_notification_prefs(member_uid)
assert get_notification_default("badge", "push") is True
def test_create_notification_honors_in_app_pref(bob):
_, user = bob
uid = _user_notification_prefs(user["username"])["uid"]
notifications = get_table("notifications")
reset_notification_prefs(uid)
try:
set_notification_pref(uid, "follow", "in_app", False)
suppressed = f"suppressed {generate_uid()}"
create_notification(uid, "follow", suppressed, uid, "/feed")
assert notifications.find_one(user_uid=uid, message=suppressed) is None
set_notification_pref(uid, "follow", "in_app", True)
delivered = f"delivered {generate_uid()}"
create_notification(uid, "follow", delivered, uid, "/feed")
assert notifications.find_one(user_uid=uid, message=delivered) is not None
finally:
reset_notification_prefs(uid)
View File
+17
View File
@@ -0,0 +1,17 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timedelta, timezone
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.utils import generate_uid
def test_notifications_counts_returns_json(alice):
page, user = alice
resp = page.request.get(f"{BASE_URL}/notifications/counts")
assert resp.status == 200
data = resp.json()
assert "notifications" in data
assert "messages" in data
assert isinstance(data["notifications"], int)
assert isinstance(data["messages"], int)
+810
View File
@@ -0,0 +1,810 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timedelta, timezone
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.utils import generate_uid
import requests
def _seed_news_seo():
from datetime import datetime, timezone
from devplacepy.database import get_table
from devplacepy.utils import generate_uid, make_combined_slug
uid = generate_uid()
title = f"SEO Test News Article {uid.split('-')[-1]}"
slug = make_combined_slug(title, uid)
get_table("news").insert(
{
"uid": uid,
"slug": slug,
"title": title,
"description": "A seeded news article for SEO tests.",
"content": "Body content for the seeded article.",
"url": "https://example.com/article",
"source_name": "ExampleSource",
"status": "published",
"synced_at": datetime.now(timezone.utc).isoformat(),
"show_on_landing": 0,
"grade": 8,
}
)
return slug, uid
def _seed_owner():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
uid = str(uuid4())
get_table("users").insert(
{
"uid": uid,
"username": f"seo_{uid[:8]}",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def _seed_post_seo(image=None):
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Post", uid)
get_table("posts").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Post",
"content": "Body text for the SEO detail post.",
"topic": "general",
"project_uid": None,
"image": image,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_gist():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Gist", uid)
get_table("gists").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Gist",
"description": "Gist description for SEO tests.",
"source_code": "print('seo')",
"language": "python",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_project():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Project", uid)
get_table("projects").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Project",
"description": "Project description for SEO tests.",
"project_type": "software",
"platforms": "Linux",
"status": "Released",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_news_image(news_uid):
from devplacepy.database import get_table
get_table("news_images").insert(
{
"news_uid": news_uid,
"url": "https://example.com/seo-news-image.jpg",
}
)
def _seed_feed_posts(count):
from datetime import datetime, timezone, timedelta
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
topic = f"seopag{owner[:8]}"
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
posts = get_table("posts")
for i in range(count):
uid = str(uuid4())
posts.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(f"seo pag {i}", uid),
"title": None,
"content": f"seo pag post {i}",
"topic": topic,
"project_uid": None,
"image": None,
"stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
}
)
return topic
def test_notifications_pagination(alice):
page, _ = alice
alice_row = get_table("users").find_one(username="alice_test")
notifications_table = get_table("notifications")
notifications_table.delete(user_uid=alice_row["uid"])
now = datetime.now(timezone.utc)
for i in range(30):
notifications_table.insert(
{
"uid": generate_uid(),
"user_uid": alice_row["uid"],
"type": "test",
"message": f"pagination-test-msg-{i:02d}",
"related_uid": alice_row["uid"],
"target_url": "/feed",
"read": False,
"created_at": (now - timedelta(seconds=i)).isoformat(),
}
)
page.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
cards = page.locator(".notification-card")
assert cards.count() == 25, f"expected 25 cards on page 1, got {cards.count()}"
load_more = page.locator(".load-more-wrap a")
load_more.wait_for(state="visible", timeout=5000)
href = load_more.get_attribute("href")
assert href and "before=" in href, f"Load More should carry cursor: {href}"
page.goto(f"{BASE_URL}{href}", wait_until="domcontentloaded")
cards2 = page.locator(".notification-card")
assert cards2.count() == 5, f"expected 5 cards on page 2, got {cards2.count()}"
assert page.locator(".load-more-wrap").count() == 0, (
"Load More should be absent on final page"
)
notifications_table.delete(user_uid=alice_row["uid"])
def test_notifications_no_load_more_when_under_page_size(alice):
page, _ = alice
alice_row = get_table("users").find_one(username="alice_test")
notifications_table = get_table("notifications")
notifications_table.delete(user_uid=alice_row["uid"])
now = datetime.now(timezone.utc)
for i in range(3):
notifications_table.insert(
{
"uid": generate_uid(),
"user_uid": alice_row["uid"],
"type": "test",
"message": f"small-set-msg-{i}",
"related_uid": alice_row["uid"],
"target_url": "/feed",
"read": False,
"created_at": (now - timedelta(seconds=i)).isoformat(),
}
)
page.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
assert page.locator(".notification-card").count() == 3
assert page.locator(".load-more-wrap").count() == 0
notifications_table.delete(user_uid=alice_row["uid"])
def test_notifications_page_loads(alice):
page, _ = alice
page.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
assert page.is_visible("h2:has-text('Notifications')")
def test_mark_all_read(alice):
page, _ = alice
page.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
clear = page.locator("form[action='/notifications/mark-all-read'] button")
if clear.is_visible():
clear.click()
page.wait_for_timeout(500)
def test_notifications_navigation(alice):
page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
assert page.is_visible("h2:has-text('Notifications')")
def test_notifications_empty_state(alice):
page, _ = alice
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):
page, _ = alice
page.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
clear = page.locator("form[action='/notifications/mark-all-read'] button")
assert clear.is_visible()
def test_notifications_topnav_user(alice):
page, user = alice
page.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
assert page.is_visible(f"text={user['username']}")
def test_notifications_avatar_visible(alice):
page, _ = alice
page.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
avatar = page.locator("img.avatar-img").first
assert avatar.is_visible()
def test_vote_notification_on_post(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
pb = ctx_b.new_page()
pa.set_default_timeout(15000)
pb.set_default_timeout(15000)
login_user(pa, seeded_db["alice"])
login_user(pb, seeded_db["bob"])
pb.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
pb.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
pb.locator(".feed-fab").first.click()
pb.fill("#post-content", "Post for vote notification test")
pb.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
pb.wait_for_url("**/posts/*", timeout=10000, wait_until="domcontentloaded")
post_url = pb.url
pa.goto(post_url, wait_until="domcontentloaded")
pa.wait_for_timeout(1000)
vote_btn = pa.locator("button.post-action-btn").filter(has_text="+").first
vote_btn.wait_for(state="visible", timeout=10000)
vote_btn.click()
pa.wait_for_timeout(1500)
pa.wait_for_timeout(500)
pa_body = pa.locator("body").text_content()
assert "Internal Server Error" not in pa_body, (
f"Alice got 500 after vote: {pa_body[:300]}"
)
pb.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
pb.wait_for_timeout(1500)
body = pb.locator("body").text_content()
assert "Internal Server Error" not in body, (
f"Got 500 error on notifications page: {body[:500]}"
)
assert "alice_test" in body, (
f"Expected 'alice_test' in notifications, got: {body[:500]}"
)
assert "++'d" in body, f"Expected '++\\'d' in notifications, got: {body[:500]}"
ctx_a.close()
ctx_b.close()
def test_comment_notification_on_post(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
pb = ctx_b.new_page()
pa.set_default_timeout(15000)
pb.set_default_timeout(15000)
login_user(pa, seeded_db["alice"])
login_user(pb, seeded_db["bob"])
pa.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
pa.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
pa.locator(".feed-fab").first.click()
pa.fill("#post-content", "Post for comment notification test")
pa.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
pa.wait_for_url("**/posts/*", timeout=10000, wait_until="domcontentloaded")
post_url = pa.url
pb.goto(post_url, wait_until="domcontentloaded")
pb.wait_for_timeout(1000)
comment_textarea = pb.locator("form.comment-form textarea[name='content']").first
comment_textarea.wait_for(state="visible", timeout=10000)
comment_textarea.fill("Nice post!")
pb.locator("button.comment-form-submit").first.click()
pb.wait_for_timeout(1500)
pb_body = pb.locator("body").text_content()
assert "Internal Server Error" not in pb_body, f"Bob got 500: {pb_body[:300]}"
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
pa.wait_for_timeout(1500)
body = pa.locator("body").text_content()
assert "Internal Server Error" not in body, (
f"Got 500 error on notifications: {body[:500]}"
)
assert "bob_test" in body, (
f"Expected 'bob_test' in notifications, got: {body[:500]}"
)
assert "commented on your" in body, (
f"Expected 'commented on your' in notifications, got: {body[:500]}"
)
ctx_a.close()
ctx_b.close()
def test_follow_notification(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
pb = ctx_b.new_page()
pa.set_default_timeout(15000)
pb.set_default_timeout(15000)
login_user(pa, seeded_db["alice"])
login_user(pb, seeded_db["bob"])
pb.goto(f"{BASE_URL}/profile/alice_test", wait_until="domcontentloaded")
pb.wait_for_timeout(1000)
follow_btn = pb.locator("form[action='/follow/alice_test'] button")
if follow_btn.is_visible():
follow_btn.click()
pb.wait_for_timeout(1000)
pb_body = pb.locator("body").text_content()
assert "Internal Server Error" not in pb_body, f"Bob got 500: {pb_body[:300]}"
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
pa.wait_for_timeout(1500)
body = pa.locator("body").text_content()
assert "Internal Server Error" not in body, (
f"Got 500 error on notifications: {body[:500]}"
)
assert "bob_test" in body, (
f"Expected 'bob_test' in notifications, got: {body[:500]}"
)
assert "started following you" in body, (
f"Expected 'started following you' in notifications, got: {body[:500]}"
)
ctx_a.close()
ctx_b.close()
def test_message_notification(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
pb = ctx_b.new_page()
pa.set_default_timeout(15000)
pb.set_default_timeout(15000)
login_user(pa, seeded_db["alice"])
login_user(pb, seeded_db["bob"])
pb.goto(f"{BASE_URL}/messages?search=alice_test", wait_until="domcontentloaded")
pb.wait_for_timeout(2000)
msg_input = pb.locator("input[name='content']").first
msg_input.wait_for(state="visible", timeout=10000)
msg_input.fill("Hello from bob_test!")
pb.locator("button[type='submit']").last.click()
pb.wait_for_timeout(1500)
pb_body = pb.locator("body").text_content()
assert "Internal Server Error" not in pb_body, f"Bob got 500: {pb_body[:300]}"
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
pa.wait_for_timeout(1500)
body = pa.locator("body").text_content()
assert "Internal Server Error" not in body, (
f"Got 500 error on notifications: {body[:500]}"
)
assert "bob_test" in body, (
f"Expected 'bob_test' in notifications, got: {body[:500]}"
)
assert "sent you a message" in body, (
f"Expected 'sent you a message' in notifications, got: {body[:500]}"
)
ctx_a.close()
ctx_b.close()
def test_mention_notification_in_post(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
pb = ctx_b.new_page()
pa.set_default_timeout(15000)
pb.set_default_timeout(15000)
login_user(pa, seeded_db["alice"])
login_user(pb, seeded_db["bob"])
pb.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
pb.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
pb.locator(".feed-fab").first.click()
pb.fill("#post-content", "Check this out @alice_test how are you?")
pb.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
pb.wait_for_url("**/posts/*", timeout=10000, wait_until="domcontentloaded")
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
pa.wait_for_timeout(2000)
body = pa.locator("body").text_content()
assert "Internal Server Error" not in body, (
f"Got 500 error on notifications: {body[:500]}"
)
assert "bob_test" in body, (
f"Expected 'bob_test' in notifications for mention, got: {body[:500]}"
)
assert "mentioned you" in body, (
f"Expected 'mentioned you' in notifications, got: {body[:500]}"
)
ctx_a.close()
ctx_b.close()
def test_mention_notification_in_comment(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
pb = ctx_b.new_page()
pa.set_default_timeout(15000)
pb.set_default_timeout(15000)
login_user(pa, seeded_db["alice"])
login_user(pb, seeded_db["bob"])
pa.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
pa.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
pa.locator(".feed-fab").first.click()
pa.fill("#post-content", "Post for mention in comment test")
pa.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
pa.wait_for_url("**/posts/*", timeout=10000, wait_until="domcontentloaded")
post_url = pa.url
pb.goto(post_url, wait_until="domcontentloaded")
pb.wait_for_timeout(1000)
comment_textarea = pb.locator("form.comment-form textarea[name='content']").first
comment_textarea.wait_for(state="visible", timeout=10000)
comment_textarea.fill("Hey @alice_test look at this!")
pb.locator("button.comment-form-submit").first.click()
pb.wait_for_timeout(1500)
pb_body = pb.locator("body").text_content()
assert "Internal Server Error" not in pb_body, f"Bob got 500: {pb_body[:300]}"
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
pa.wait_for_timeout(2000)
body = pa.locator("body").text_content()
assert "Internal Server Error" not in body, (
f"Got 500 error on notifications: {body[:500]}"
)
assert "bob_test" in body, (
f"Expected 'bob_test' in notifications for mention, got: {body[:500]}"
)
assert "mentioned you" in body, (
f"Expected 'mentioned you' in notifications, got: {body[:500]}"
)
ctx_a.close()
ctx_b.close()
def test_reply_notification(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
pb = ctx_b.new_page()
pa.set_default_timeout(15000)
pb.set_default_timeout(15000)
login_user(pa, seeded_db["alice"])
login_user(pb, seeded_db["bob"])
pa.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
pa.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
pa.locator(".feed-fab").first.click()
pa.fill("#post-content", "Post for reply notification test")
pa.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
pa.wait_for_url("**/posts/*", timeout=10000, wait_until="domcontentloaded")
post_url = pa.url
pa.goto(post_url, wait_until="domcontentloaded")
pa.wait_for_timeout(1000)
comment_textarea = pa.locator("form.comment-form textarea[name='content']").first
comment_textarea.wait_for(state="visible", timeout=10000)
comment_textarea.fill("Alice's top-level comment")
pa.locator("button.comment-form-submit").first.click()
pa.wait_for_timeout(1500)
pb.goto(post_url, wait_until="domcontentloaded")
pb.wait_for_timeout(1000)
reply_btn = pb.locator("button:has-text('Reply')").first
if reply_btn.is_visible():
reply_btn.click()
pb.wait_for_timeout(500)
reply_textarea = pb.locator("form.comment-form textarea[name='content']").first
if reply_textarea.is_visible():
reply_textarea.fill("Bob's reply to Alice's comment")
pb.locator("button.comment-form-submit").first.click()
pb.wait_for_timeout(1500)
pb_body = pb.locator("body").text_content()
assert "Internal Server Error" not in pb_body, f"Bob got 500: {pb_body[:300]}"
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
pa.wait_for_timeout(2000)
body = pa.locator("body").text_content()
assert "Internal Server Error" not in body, (
f"Got 500 error on notifications: {body[:500]}"
)
assert "bob_test" in body, (
f"Expected 'bob_test' in notifications, got: {body[:500]}"
)
assert "replied to your comment" in body, (
f"Expected 'replied to your comment' in notifications, got: {body[:500]}"
)
ctx_a.close()
ctx_b.close()
def test_mention_notification_names_actor(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
pb = ctx_b.new_page()
pa.set_default_timeout(15000)
pb.set_default_timeout(15000)
login_user(pa, seeded_db["alice"])
login_user(pb, seeded_db["bob"])
pb.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
pb.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
pb.locator(".feed-fab").first.click()
pb.fill("#post-content", "Naming check @alice_test please read")
pb.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
pb.wait_for_url("**/posts/*", timeout=10000, wait_until="domcontentloaded")
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
pa.wait_for_timeout(1500)
texts = pa.locator(".notification-text").all_text_contents()
assert any("@bob_test mentioned you" in t for t in texts), (
f"mention message must name the actor (bob): {texts}"
)
assert not any("@alice_test mentioned you" in t for t in texts), (
f"mention message must not name the mentioned user (alice): {texts}"
)
ctx_a.close()
ctx_b.close()
def test_comment_notification_click_opens_comment(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
pb = ctx_b.new_page()
pa.set_default_timeout(15000)
pb.set_default_timeout(15000)
login_user(pa, seeded_db["alice"])
login_user(pb, seeded_db["bob"])
pa.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
pa.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
pa.locator(".feed-fab").first.click()
pa.fill("#post-content", "Post for click navigation test")
pa.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
pa.wait_for_url("**/posts/*", timeout=10000, wait_until="domcontentloaded")
post_url = pa.url
pb.goto(post_url, wait_until="domcontentloaded")
textarea = pb.locator("form.comment-form textarea[name='content']").first
textarea.wait_for(state="visible", timeout=10000)
textarea.fill("Bob comment that alice should jump to")
pb.locator("button.comment-form-submit").first.click()
pb.wait_for_timeout(1500)
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
card = (
pa.locator(".notification-card").filter(has_text="commented on your post").first
)
card.wait_for(state="visible", timeout=10000)
href = card.locator("a.card-link").get_attribute("href")
assert href.startswith("/notifications/open/"), f"unexpected href: {href}"
card.locator("a.card-link").click()
pa.wait_for_url("**/posts/**", timeout=10000, wait_until="domcontentloaded")
assert "#comment-" in pa.url, f"click did not deep-link to a comment: {pa.url}"
comment_uid = pa.url.split("#comment-")[1]
pa.locator(f"#comment-{comment_uid}").wait_for(state="visible", timeout=10000)
pa.wait_for_selector(f"#comment-{comment_uid}.comment-highlight", timeout=5000)
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
pa.wait_for_timeout(1000)
target = pa.locator(f'.notification-card:has(a.card-link[href="{href}"])')
target.wait_for(state="visible", timeout=10000)
assert "unread" not in (target.get_attribute("class") or ""), (
"opening a notification should mark it read"
)
ctx_a.close()
ctx_b.close()
def test_follow_notification_click_opens_profile(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
pb = ctx_b.new_page()
pa.set_default_timeout(15000)
pb.set_default_timeout(15000)
login_user(pa, seeded_db["alice"])
login_user(pb, seeded_db["bob"])
pb.goto(f"{BASE_URL}/profile/alice_test", wait_until="domcontentloaded")
pb.wait_for_timeout(1000)
follow_btn = pb.locator("form[action='/follow/alice_test'] button")
if follow_btn.is_visible():
follow_btn.click()
pb.wait_for_timeout(1000)
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
card = (
pa.locator(".notification-card").filter(has_text="started following you").first
)
card.wait_for(state="visible", timeout=10000)
card.locator("a.card-link").click()
pa.wait_for_url("**/profile/bob_test", timeout=10000, wait_until="domcontentloaded")
assert pa.url.endswith("/profile/bob_test"), (
f"follow notification should open the follower profile: {pa.url}"
)
ctx_a.close()
ctx_b.close()
def test_message_notification_click_opens_conversation(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
pb = ctx_b.new_page()
pa.set_default_timeout(15000)
pb.set_default_timeout(15000)
login_user(pa, seeded_db["alice"])
login_user(pb, seeded_db["bob"])
pb.goto(f"{BASE_URL}/messages?search=alice_test", wait_until="domcontentloaded")
pb.wait_for_timeout(2000)
msg_input = pb.locator("input[name='content']").first
msg_input.wait_for(state="visible", timeout=10000)
msg_input.fill("Click-through message from bob")
pb.locator("button[type='submit']").last.click()
pb.wait_for_timeout(1500)
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
card = pa.locator(".notification-card").filter(has_text="sent you a message").first
card.wait_for(state="visible", timeout=10000)
card.locator("a.card-link").click()
pa.wait_for_url("**/messages**", timeout=10000, wait_until="domcontentloaded")
assert "with_uid=" in pa.url, (
f"message notification should open the conversation: {pa.url}"
)
ctx_a.close()
ctx_b.close()
def test_vote_notification_click_opens_target(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
pb = ctx_b.new_page()
pa.set_default_timeout(15000)
pb.set_default_timeout(15000)
login_user(pa, seeded_db["alice"])
login_user(pb, seeded_db["bob"])
pb.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
pb.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
pb.locator(".feed-fab").first.click()
pb.fill("#post-content", "Post for vote click-through test")
pb.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
pb.wait_for_url("**/posts/*", timeout=10000, wait_until="domcontentloaded")
post_path = "/posts/" + pb.url.split("/posts/")[1]
pa.goto(f"{BASE_URL}{post_path}", wait_until="domcontentloaded")
pa.wait_for_timeout(1000)
vote_btn = pa.locator("button.post-action-btn").filter(has_text="+").first
vote_btn.wait_for(state="visible", timeout=10000)
vote_btn.click()
pa.wait_for_timeout(1500)
pb.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
card = pb.locator(".notification-card").filter(has_text="++'d").first
card.wait_for(state="visible", timeout=10000)
card.locator("a.card-link").click()
pb.wait_for_url(f"**{post_path}", timeout=10000, wait_until="domcontentloaded")
assert post_path in pb.url, (
f"vote notification should open the voted post: {pb.url}"
)
ctx_a.close()
ctx_b.close()
def test_notifications_page_is_noindex(alice):
page, user = alice
page.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
page.wait_for_url("**/notifications", wait_until="domcontentloaded")
meta = page.locator('meta[name="robots"]')
content = meta.get_attribute("content")
assert content and "noindex" in content
View File
View File
+141
View File
@@ -0,0 +1,141 @@
# retoor <retoor@molodetz.nl>
import json
import requests
from starlette.requests import Request
from tests.conftest import BASE_URL, run_async
from devplacepy.database import get_table, set_setting
from devplacepy.utils import generate_uid
import devplacepy.services.openai_gateway.gateway as gwmod
from devplacepy.services.openai_gateway import GatewayService
class FakeResp_openai_gateway:
def __init__(self, status=200, payload=None, ctype="application/json", content=b""):
self.status_code = status
self._payload = payload
self.text = json.dumps(payload) if payload is not None else ""
self.headers = {"content-type": ctype}
self.content = content
def json(self):
if self._payload is None:
raise ValueError("no json")
return self._payload
class FakeRequest:
def __init__(self, method, url, json_body):
self.method = method
self.url = url
self.json_body = json_body
self.extensions = {}
class FakeClient_openai_gateway:
def __init__(self, *a, **k):
self.calls = []
def build_request(self, method, url, headers=None, json=None, content=None):
return FakeRequest(method, url, json)
async def send(self, request):
self.calls.append((request.url, request.json_body))
body = request.json_body or {}
return FakeResp_openai_gateway(
payload={
"id": "x",
"model": body.get("model"),
"choices": [{"message": {"content": "hi there"}}],
}
)
async def post(self, url, headers=None, json=None, timeout=None):
self.calls.append((url, json))
return FakeResp_openai_gateway(
payload={
"id": "x",
"model": json.get("model"),
"choices": [{"message": {"content": "hi there"}}],
}
)
async def request(self, method, url, headers=None, content=None):
return FakeResp_openai_gateway(payload={"ok": True})
async def aclose(self):
pass
def _make_request_openai_gateway(headers=None, cookies=None):
headers = headers or {}
raw = [(k.lower().encode(), v.encode()) for k, v in headers.items()]
if cookies:
raw.append(
(b"cookie", "; ".join(f"{k}={v}" for k, v in cookies.items()).encode())
)
return Request(
{
"type": "http",
"method": "POST",
"path": "/openai/v1/chat/completions",
"query_string": b"",
"headers": raw,
"state": {},
}
)
def _make_admin_openai_gateway(role="Admin"):
username = f"gw_{generate_uid()[:8]}"
api_key = generate_uid()
get_table("users").insert(
{
"uid": generate_uid(),
"username": username,
"email": f"{username}@t.dev",
"api_key": api_key,
"role": role,
"is_active": True,
}
)
return username, api_key
def _config(page, **fields):
page.request.post(f"{BASE_URL}/admin/services/openai/config", form=fields)
def test_gateway_disabled_returns_503(alice):
page, _ = alice
page.request.post(f"{BASE_URL}/admin/services/openai/stop")
try:
r = requests.post(
f"{BASE_URL}/openai/v1/chat/completions", json={"messages": []}
)
assert r.status_code == 503
finally:
page.request.post(f"{BASE_URL}/admin/services/openai/start")
def test_gateway_auth_and_routing(alice):
page, user = alice
page.request.post(f"{BASE_URL}/admin/services/openai/start")
_config(
page,
gateway_upstream_url="http://127.0.0.1:9/chat/completions",
gateway_vision_enabled="0",
gateway_allow_admins="1",
gateway_access_key="gwkey",
)
try:
no_creds = requests.post(
f"{BASE_URL}/openai/v1/chat/completions", json={"messages": []}
)
assert no_creds.status_code == 401
with_key = requests.post(
f"{BASE_URL}/openai/v1/chat/completions",
headers={"X-API-KEY": "gwkey"},
json={"messages": [{"role": "user", "content": "hi"}]},
)
assert with_key.status_code == 502 # auth passed, upstream unreachable
admin_key = get_table("users").find_one(username=user["username"])["api_key"]
with_admin = requests.post(
f"{BASE_URL}/openai/v1/chat/completions",
headers={"Authorization": f"Bearer {admin_key}"},
json={"messages": [{"role": "user", "content": "hi"}]},
)
assert with_admin.status_code == 502
finally:
_config(page, gateway_access_key="")
page.request.post(f"{BASE_URL}/admin/services/openai/start")
+50
View File
@@ -0,0 +1,50 @@
# retoor <retoor@molodetz.nl>
import time
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
DEFAULT_MAINTENANCE_MESSAGE = (
"DevPlace is undergoing scheduled maintenance. Please check back shortly."
)
OPERATIONAL_FIELDS = (
"rate_limit_per_minute",
"rate_limit_window_seconds",
"session_max_age_days",
"session_remember_days",
"registration_open",
"maintenance_mode",
"maintenance_message",
)
def _save_settings(page, **fields):
page.goto(f"{BASE_URL}/admin/settings", wait_until="domcontentloaded")
for name, value in fields.items():
locator = page.locator(f"#{name}")
tag = locator.evaluate("el => el.tagName.toLowerCase()")
if tag == "select":
page.select_option(f"#{name}", value)
else:
locator.fill(value)
page.click("button:has-text('Save Settings')")
page.wait_for_url("**/admin/settings", wait_until="domcontentloaded")
def test_operational_settings_persist(alice):
page, _ = alice
try:
_save_settings(
page,
session_remember_days="14",
maintenance_message="Custom maintenance text",
)
assert page.locator("#session_remember_days").input_value() == "14"
assert (
page.locator("#maintenance_message").input_value()
== "Custom maintenance text"
)
finally:
_save_settings(
page,
session_remember_days="30",
maintenance_message=DEFAULT_MAINTENANCE_MESSAGE,
)
+314
View File
@@ -0,0 +1,314 @@
# retoor <retoor@molodetz.nl>
import re
from playwright.sync_api import expect
from tests.conftest import BASE_URL, assert_share_copies
def create_post(page, topic="random", content="Test post content", title=None):
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
page.locator(".feed-fab").first.click()
page.check(f"input[value='{topic}']")
page.fill("#post-content", content)
if title:
page.fill("#post-title", title)
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
def _profile_stars(page, username):
page.goto(f"{BASE_URL}/profile/{username}", wait_until="domcontentloaded")
value = page.locator(
".profile-stat:has(.profile-stat-label:has-text('Stars')) .profile-stat-value"
).first
return int(value.text_content().strip())
def test_post_detail_page(alice):
page, _ = alice
create_post(page, "random", "Detail page test content")
assert page.is_visible("text=Detail page test content")
assert page.is_visible("text=Back to Feed")
def test_post_topic_badge(alice):
page, _ = alice
create_post(page, "question", "Question post for badge check")
badge = page.locator(".badge-question")
assert badge.is_visible()
def test_post_author_info(alice):
page, user = alice
create_post(page, "fun", "Post with author check")
assert page.is_visible(f"text={user['username']}")
def test_post_vote_button(alice):
page, _ = alice
create_post(page, "devlog", "Votable post")
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")
page.locator(".post-action-btn.vote-up").first.click()
expect(page.locator(".post-vote-count").first).to_have_text("1")
def test_post_upvote_voted_state_persists(alice):
page, _ = alice
create_post(page, "showcase", "Upvote persistence test")
page.locator(".post-action-btn.vote-up").first.click()
expect(page.locator(".post-vote-count").first).to_have_text("1")
page.reload(wait_until="domcontentloaded")
expect(page.locator(".post-action-btn.vote-up").first).to_have_class(
re.compile(r"\bvoted\b")
)
assert "voted" not in (
page.locator(".post-action-btn.vote-down").first.get_attribute("class") or ""
)
def test_post_downvote_voted_state_persists(alice):
page, _ = alice
create_post(page, "showcase", "Downvote persistence test")
page.locator(".post-action-btn.vote-down").first.click()
expect(page.locator(".post-vote-count").first).to_have_text("-1")
page.reload(wait_until="domcontentloaded")
expect(page.locator(".post-action-btn.vote-down").first).to_have_class(
re.compile(r"\bvoted\b")
)
assert "voted" not in (
page.locator(".post-action-btn.vote-up").first.get_attribute("class") or ""
)
def test_comment_voted_state_persists(alice):
page, _ = alice
create_post(page, "devlog", "Post for comment vote persistence")
textarea = page.locator(".comment-form textarea[name='content']")
textarea.fill("Comment whose vote should persist")
page.locator(".comment-form button:has-text('Post')").click()
expect(
page.locator(".comment-text:has-text('Comment whose vote should persist')")
).to_be_visible()
page.locator(".comment-vote-btn").first.click()
expect(page.locator(".comment-vote-btn").first).to_have_class(
re.compile(r"\bvoted\b")
)
page.reload(wait_until="domcontentloaded")
expect(page.locator(".comment-vote-btn").first).to_have_class(
re.compile(r"\bvoted\b")
)
def test_profile_stars_reflect_content_votes(alice):
page, user = alice
before = _profile_stars(page, user["username"])
create_post(page, "devlog", "Reputation contribution post")
page.locator(".post-action-btn.vote-up").first.click()
expect(page.locator(".post-vote-count").first).to_have_text("1")
after = _profile_stars(page, user["username"])
assert after == before + 1, f"expected stars {before + 1}, got {after}"
def test_post_comments_section(alice):
page, _ = alice
create_post(page, "rant", "Comment section test")
assert page.is_visible("text=Comments")
assert page.is_visible("textarea[placeholder='Your opinion goes here...']")
def test_add_comment(alice):
page, _ = alice
create_post(page, "random", "Post for commenting")
textarea = page.locator(".comment-form textarea[name='content']")
textarea.fill("This is a test comment from Playwright")
page.locator(".comment-form button:has-text('Post')").click()
expect(
page.locator(".comment-text:has-text('This is a test comment from Playwright')")
).to_be_visible()
def test_comment_voting(alice):
page, _ = alice
create_post(page, "devlog", "Post for comment voting")
textarea = page.locator(".comment-form textarea[name='content']")
textarea.fill("Votable comment")
page.locator(".comment-form button:has-text('Post')").click()
expect(page.locator(".comment-text:has-text('Votable comment')")).to_be_visible()
vote_btns = page.locator(".comment-vote-btn")
upvote = vote_btns.first
upvote.click()
expect(vote_btns.first).to_have_class(re.compile(r"\bvoted\b"))
def test_delete_own_comment(alice):
page, _ = alice
create_post(page, "fun", "Post for comment deletion")
textarea = page.locator(".comment-form textarea[name='content']")
textarea.fill("Comment to delete")
page.locator(".comment-form button:has-text('Post')").click()
comment = page.locator(".comment-text:has-text('Comment to delete')")
expect(comment).to_be_visible()
delete_btn = page.locator(".comment-action-btn:has-text('Delete')").last
expect(delete_btn).to_be_visible()
delete_btn.click()
page.locator(".dialog-overlay.visible .dialog-confirm").click()
expect(comment).to_have_count(0)
def test_comment_form_elements(alice):
page, _ = alice
create_post(page, "random", "Post for checking comment form")
assert page.is_visible("textarea[placeholder='Your opinion goes here...']")
def test_share_button(alice):
page, _ = alice
create_post(page, "showcase", "Share button check")
assert_share_copies(page, "/posts/")
def test_multiple_comments_on_post(alice):
page, _ = alice
create_post(page, "devlog", "Post with many comments")
for i in range(3):
page.locator(".comment-form textarea[name='content']").fill(
f"Comment number {i + 1}"
)
page.locator(".comment-form button:has-text('Post')").click()
expect(
page.locator(f".comment-text:has-text('Comment number {i + 1}')")
).to_be_visible()
assert page.is_visible("text=Comment number 1")
assert page.is_visible("text=Comment number 3")
def test_comment_and_vote_then_delete(alice):
page, _ = alice
create_post(page, "showcase", "Full comment lifecycle post")
page.locator(".comment-form textarea[name='content']").fill("Lifecycle comment")
page.locator(".comment-form button:has-text('Post')").click()
comment = page.locator(".comment-text:has-text('Lifecycle comment')")
expect(comment).to_be_visible()
vote_up = page.locator(".comment-vote-btn").first
vote_up.click()
expect(page.locator(".comment-vote-btn").first).to_have_class(
re.compile(r"\bvoted\b")
)
delete_btn = page.locator(".comment-action-btn:has-text('Delete')").last
expect(delete_btn).to_be_visible()
delete_btn.click()
page.locator(".dialog-overlay.visible .dialog-confirm").click()
expect(comment).to_have_count(0)
def test_comment_reply_inline_form(alice):
page, _ = alice
create_post(page, "devlog", "Post for inline reply")
page.locator(".comment-form textarea[name='content']").fill("Parent comment here")
page.locator(".comment-form button:has-text('Post')").click()
expect(
page.locator(".comment-text:has-text('Parent comment here')")
).to_be_visible()
page.locator(".comment [data-action='reply']").first.click()
reply_form = page.locator(".comment-reply-form").first
expect(reply_form).to_be_visible()
reply_form.locator("textarea[name='content']").fill("Inline reply text")
reply_form.locator("button:has-text('Post')").click()
expect(
page.locator(".comment-replies .comment-text:has-text('Inline reply text')")
).to_be_visible()
def test_comment_reply_toggle_and_cancel(alice):
page, _ = alice
create_post(page, "devlog", "Post for reply toggle")
page.locator(".comment-form textarea[name='content']").fill("Toggle parent")
page.locator(".comment-form button:has-text('Post')").click()
expect(page.locator(".comment-text:has-text('Toggle parent')")).to_be_visible()
reply_btn = page.locator(".comment [data-action='reply']").first
reply_btn.click()
expect(page.locator(".comment-reply-form")).to_have_count(1)
reply_btn.click()
expect(page.locator(".comment-reply-form")).to_have_count(0)
reply_btn.click()
expect(page.locator(".comment-reply-form")).to_have_count(1)
page.locator(".comment-reply-cancel").first.click()
expect(page.locator(".comment-reply-form")).to_have_count(0)
def test_comment_create_scrolls_to_anchor(alice):
page, _ = alice
create_post(page, "devlog", "Post for comment anchor")
page.locator(".comment-form textarea[name='content']").fill("Anchor comment text")
page.locator(".comment-form button:has-text('Post')").click()
expect(
page.locator(".comment-text:has-text('Anchor comment text')")
).to_be_visible()
assert "#comment-" in page.url
comment_uid = page.url.split("#comment-")[1]
page.wait_for_selector(f"#comment-{comment_uid}.comment-highlight", timeout=5000)
expect(page.locator(f"#comment-{comment_uid}")).to_contain_text(
"Anchor comment text"
)
def test_post_across_all_topics(alice):
page, _ = alice
topics = ["devlog", "showcase", "question", "rant", "fun", "signals"]
for topic in topics:
create_post(page, topic, f"Topic test post for {topic}")
badge = page.locator(f".badge-{topic}")
expect(badge).to_be_visible()
def test_content_rendering_markdown_and_highlight(alice):
page, _ = alice
create_post(
page, "random", "Hello **world bold** text\n\n```python\nprint('hi')\n```"
)
page.locator(".rendered-content strong").first.wait_for(state="visible")
assert page.locator(".rendered-content pre code").count() >= 1
def test_mention_autocomplete(alice):
page, _ = alice
create_post(page, "random", "Post for mention autocomplete here")
ta = page.locator(".comment-form textarea[data-mention]").first
ta.click()
ta.press_sequentially("@bob")
item = page.locator(".mention-dropdown-item").first
item.wait_for(state="visible")
assert "bob" in item.inner_text().lower()
def test_emoji_picker_opens(alice):
page, _ = alice
create_post(page, "random", "Post for emoji picker here")
page.locator(".emoji-toggle-btn").first.click()
page.locator(".emoji-picker-wrapper").first.wait_for(state="visible")
assert page.locator("emoji-picker").first.is_visible()
def test_attachment_upload_ui(alice):
import io
from PIL import Image
page, _ = alice
create_post(page, "random", "Post for attachment upload UI")
buf = io.BytesIO()
Image.new("RGB", (4, 4), (0, 128, 255)).save(buf, "PNG")
file_input = page.locator(".comment-form dp-upload .dp-upload-input").first
file_input.set_input_files(
{"name": "pic.png", "mimeType": "image/png", "buffer": buf.getvalue()}
)
page.locator(".comment-form dp-upload .dp-upload-chip").first.wait_for(
state="visible", timeout=15000
)
expect(
page.locator(".comment-form dp-upload input[name='attachment_uids']")
).to_have_value(re.compile(r".+"))
View File
+236
View File
@@ -0,0 +1,236 @@
# retoor <retoor@molodetz.nl>
import time
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
AJAX_comments = {"X-Requested-With": "fetch"}
_counter_comments = [0]
def _session_comments():
_counter_comments[0] += 1
name = f"cmt{int(time.time() * 1000)}{_counter_comments[0]}"
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _create_post_comments(session, title):
r = session.post(
f"{BASE_URL}/posts/create",
data={
"content": "Post for comment test.",
"title": title,
"topic": "devlog",
},
allow_redirects=False,
)
slug = r.headers["location"].split("/posts/")[-1]
return get_table("posts").find_one(slug=slug)["uid"]
def _seed_news_seo():
from datetime import datetime, timezone
from devplacepy.database import get_table
from devplacepy.utils import generate_uid, make_combined_slug
uid = generate_uid()
title = f"SEO Test News Article {uid.split('-')[-1]}"
slug = make_combined_slug(title, uid)
get_table("news").insert(
{
"uid": uid,
"slug": slug,
"title": title,
"description": "A seeded news article for SEO tests.",
"content": "Body content for the seeded article.",
"url": "https://example.com/article",
"source_name": "ExampleSource",
"status": "published",
"synced_at": datetime.now(timezone.utc).isoformat(),
"show_on_landing": 0,
"grade": 8,
}
)
return slug, uid
def _seed_owner():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
uid = str(uuid4())
get_table("users").insert(
{
"uid": uid,
"username": f"seo_{uid[:8]}",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def _seed_post_seo(image=None):
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Post", uid)
get_table("posts").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Post",
"content": "Body text for the SEO detail post.",
"topic": "general",
"project_uid": None,
"image": image,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_gist():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Gist", uid)
get_table("gists").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Gist",
"description": "Gist description for SEO tests.",
"source_code": "print('seo')",
"language": "python",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_project():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Project", uid)
get_table("projects").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Project",
"description": "Project description for SEO tests.",
"project_type": "software",
"platforms": "Linux",
"status": "Released",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_news_image(news_uid):
from devplacepy.database import get_table
get_table("news_images").insert(
{
"news_uid": news_uid,
"url": "https://example.com/seo-news-image.jpg",
}
)
def _seed_feed_posts(count):
from datetime import datetime, timezone, timedelta
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
topic = f"seopag{owner[:8]}"
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
posts = get_table("posts")
for i in range(count):
uid = str(uuid4())
posts.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(f"seo pag {i}", uid),
"title": None,
"content": f"seo pag post {i}",
"topic": topic,
"project_uid": None,
"image": None,
"stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
}
)
return topic
def test_comment_form_submission_ui(alice, app_server):
page, user = alice
title = f"ui-comment-{int(time.time() * 1000)}"
s = requests.Session()
s.post(
f"{BASE_URL}/auth/login",
data={"email": user["email"], "password": user["password"]},
)
r = s.post(
f"{BASE_URL}/posts/create",
data={"content": "UI comment target.", "title": title, "topic": "devlog"},
allow_redirects=False,
)
slug = r.headers["location"].split("/posts/")[-1]
page.goto(f"{BASE_URL}/posts/{slug}", wait_until="domcontentloaded")
textarea = page.locator("form.comment-form textarea[name='content']").first
textarea.wait_for(state="visible", timeout=5000)
textarea.fill("UI test comment")
page.locator("button.comment-form-submit").first.click()
page.wait_for_timeout(1000)
assert page.is_visible("text=UI test comment")
def test_post_og_image_uses_content_image(page, app_server):
slug, _ = _seed_post_seo(image="seo-content.png")
page.goto(f"{BASE_URL}/posts/{slug}", wait_until="domcontentloaded")
og = page.locator('meta[property="og:image"]').get_attribute("content")
assert og.endswith("/static/uploads/seo-content.png"), og
def test_post_page_has_breadcrumb_schema(page, app_server):
slug, _ = _seed_post_seo()
page.goto(f"{BASE_URL}/posts/{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 "BreadcrumbList" in text
def test_post_schema_has_date_modified(page, app_server):
slug, _ = _seed_post_seo()
page.goto(f"{BASE_URL}/posts/{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 "dateModified" in text
def test_post_detail_has_no_rel_next(page, app_server):
slug, _ = _seed_post_seo()
page.goto(f"{BASE_URL}/posts/{slug}", wait_until="domcontentloaded")
assert page.locator('link[rel="next"]').count() == 0
+515
View File
@@ -0,0 +1,515 @@
# retoor <retoor@molodetz.nl>
import re
from playwright.sync_api import expect
from tests.conftest import BASE_URL
def _open_composer(page):
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
page.locator(".feed-fab").first.click()
def _create_plain_post_engagement_ui(page, content):
_open_composer(page)
page.fill("#post-content", content)
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
from uuid import uuid4
from datetime import datetime, timedelta, timezone
from tests.conftest import BASE_URL, assert_share_copies
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
def _seed_posts(count):
suite = uuid4().hex[:8]
topic = f"pag{suite}"
users = get_table("users")
posts = get_table("posts")
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
for i in range(count):
owner = str(uuid4())
users.insert(
{
"uid": owner,
"username": f"pag_{suite}_{i}",
"email": f"{suite}_{i}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
uid = str(uuid4())
content = f"Paginated post {i}"
posts.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(content, uid),
"title": None,
"content": content,
"topic": topic,
"project_uid": None,
"image": None,
"stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
"deleted_at": None,
"deleted_by": None,
}
)
return topic
def _seed_post_with_comments(comment_texts, topic="devlog"):
owner = str(uuid4())
get_table("users").insert(
{
"uid": owner,
"username": f"seed_{owner[:8]}",
"email": f"{owner[:8]}@seed.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
post_uid = str(uuid4())
marker = f"seedpost-{post_uid[:8]}"
get_table("posts").insert(
{
"uid": post_uid,
"user_uid": owner,
"slug": make_combined_slug(marker, post_uid),
"title": None,
"content": marker,
"topic": topic,
"project_uid": None,
"image": None,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
comments = get_table("comments")
base = datetime.now(timezone.utc)
for i, text in enumerate(comment_texts):
comments.insert(
{
"uid": str(uuid4()),
"target_type": "post",
"target_uid": post_uid,
"post_uid": post_uid,
"user_uid": owner,
"content": text,
"parent_uid": None,
"created_at": (base + timedelta(seconds=i)).isoformat(),
}
)
return marker, post_uid
def _create_post_ui(page, content, topic="devlog"):
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
page.locator(".feed-fab").first.click()
page.check(f"input[value='{topic}']")
page.fill("#post-content", content)
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
def _seed_searchable_posts():
token = uuid4().hex[:10]
users = get_table("users")
posts = get_table("posts")
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
match_title = f"Findable post {token}"
other_title = f"Unrelated post {uuid4().hex[:10]}"
for offset, title in ((0, match_title), (1, other_title)):
owner = str(uuid4())
users.insert(
{
"uid": owner,
"username": f"srch_{owner[:8]}",
"email": f"{owner[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
uid = str(uuid4())
posts.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(title, uid),
"title": title,
"content": f"Body for {title}",
"topic": "devlog",
"project_uid": None,
"image": None,
"stars": 0,
"created_at": (base - timedelta(seconds=offset)).isoformat(),
"deleted_at": None,
"deleted_by": None,
}
)
return token, match_title, other_title
def create_post(page, topic="random", content="Test post content", title=None):
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
page.locator(".feed-fab").first.click()
page.check(f"input[value='{topic}']")
page.fill("#post-content", content)
if title:
page.fill("#post-title", title)
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
def _profile_stars(page, username):
page.goto(f"{BASE_URL}/profile/{username}", wait_until="domcontentloaded")
value = page.locator(
".profile-stat:has(.profile-stat-label:has-text('Stars')) .profile-stat-value"
).first
return int(value.text_content().strip())
import requests
def _seed_news_seo():
from datetime import datetime, timezone
from devplacepy.database import get_table
from devplacepy.utils import generate_uid, make_combined_slug
uid = generate_uid()
title = f"SEO Test News Article {uid.split('-')[-1]}"
slug = make_combined_slug(title, uid)
get_table("news").insert(
{
"uid": uid,
"slug": slug,
"title": title,
"description": "A seeded news article for SEO tests.",
"content": "Body content for the seeded article.",
"url": "https://example.com/article",
"source_name": "ExampleSource",
"status": "published",
"synced_at": datetime.now(timezone.utc).isoformat(),
"show_on_landing": 0,
"grade": 8,
}
)
return slug, uid
def _seed_owner():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
uid = str(uuid4())
get_table("users").insert(
{
"uid": uid,
"username": f"seo_{uid[:8]}",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def _seed_post_seo(image=None):
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Post", uid)
get_table("posts").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Post",
"content": "Body text for the SEO detail post.",
"topic": "general",
"project_uid": None,
"image": image,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_gist():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Gist", uid)
get_table("gists").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Gist",
"description": "Gist description for SEO tests.",
"source_code": "print('seo')",
"language": "python",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_project():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Project", uid)
get_table("projects").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Project",
"description": "Project description for SEO tests.",
"project_type": "software",
"platforms": "Linux",
"status": "Released",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_news_image(news_uid):
from devplacepy.database import get_table
get_table("news_images").insert(
{
"news_uid": news_uid,
"url": "https://example.com/seo-news-image.jpg",
}
)
def _seed_feed_posts(count):
from datetime import datetime, timezone, timedelta
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
topic = f"seopag{owner[:8]}"
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
posts = get_table("posts")
for i in range(count):
uid = str(uuid4())
posts.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(f"seo pag {i}", uid),
"title": None,
"content": f"seo pag post {i}",
"topic": topic,
"project_uid": None,
"image": None,
"stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
}
)
return topic
def test_create_poll_and_vote(alice):
page, _ = alice
_open_composer(page)
page.fill("#post-content", "Post that carries a poll for the UI test.")
page.locator("[data-poll-toggle]").click()
page.fill("#create-post-modal input[name='poll_question']", "Tabs or spaces?")
options = page.locator("#create-post-modal input[name='poll_options']")
options.nth(0).fill("Tabs")
options.nth(1).fill("Spaces")
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
expect(page.locator(".poll-question")).to_have_text("Tabs or spaces?")
expect(page.locator(".poll-option")).to_have_count(2)
page.locator(".poll-option").first.click()
expect(page.locator(".poll-option").first).to_have_class(re.compile(r"\bchosen\b"))
expect(
page.locator(".poll-option").first.locator(".poll-option-pct")
).to_be_visible()
def test_remove_poll_does_not_create_poll(alice):
page, _ = alice
_open_composer(page)
page.fill(
"#post-content", "Post where the poll is added then removed before posting."
)
page.locator("[data-poll-toggle]").click()
page.fill("#create-post-modal input[name='poll_question']", "Discarded question?")
options = page.locator("#create-post-modal input[name='poll_options']")
options.nth(0).fill("One")
options.nth(1).fill("Two")
page.locator("[data-poll-toggle]").click()
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
expect(page.locator(".poll")).to_have_count(0)
def test_add_poll_to_existing_post_via_edit(alice):
page, _ = alice
_create_plain_post_engagement_ui(page, "Post that gains a poll through the edit modal.")
expect(page.locator(".poll")).to_have_count(0)
page.locator("[data-modal='edit-post-modal']").click()
modal = page.locator("#edit-post-modal")
expect(modal).to_be_visible()
modal.locator("[data-poll-toggle]").click()
modal.locator("input[name='poll_question']").fill("Do you like hedgehogs?")
options = modal.locator("input[name='poll_options']")
options.nth(0).fill("Yes")
options.nth(1).fill("They are spiky")
modal.locator("button.btn-primary:has-text('Save Changes')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
expect(page.locator(".poll-question")).to_have_text("Do you like hedgehogs?")
expect(page.locator(".poll-option")).to_have_count(2)
def test_feed_vote_voted_state_persists(alice):
page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").first.click()
page.fill("#post-content", "Feed vote persistence 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")
page.locator(".post-action-btn.vote-up").first.click()
expect(page.locator(".post-vote-count").first).to_have_text("1")
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
expect(page.locator(".post-action-btn.vote-up").first).to_have_class(
re.compile(r"\bvoted\b")
)
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_create_post_devlog(alice):
page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").click()
page.check("input[value='devlog']")
page.fill("#post-content", "This is a test devlog post created by Playwright")
page.fill("#post-title", "Test Devlog Title")
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
assert page.is_visible("text=Test Devlog Title")
assert page.is_visible("text=This is a test devlog post created by Playwright")
def test_create_post_showcase(alice):
page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").click()
page.check("input[value='showcase']")
page.fill("#post-content", "Check out my new project showcase")
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
def test_create_post_without_title(alice):
page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").click()
page.check("input[value='rant']")
page.fill("#post-content", "Rant without a title")
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
assert page.is_visible("text=Rant without a title")
def test_feed_inline_comment(alice):
page, _ = alice
marker = f"inline-{uuid4().hex[:8]}"
_create_post_ui(page, f"{marker} post with inline comment test")
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
card = page.locator(".post-card").filter(has_text=marker).first
inline_form = card.locator(".comment-form")
expect(inline_form).to_be_visible()
inline_form.locator("textarea[name='content']").fill("Inline comment from feed")
inline_form.locator("button.comment-form-submit").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
expect(
page.locator(".comment-text:has-text('Inline comment from feed')")
).to_be_visible()
def test_post_edit_button(alice):
page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").click()
page.fill("#post-content", "Post to be edited later")
page.fill("#post-title", "Editable Post")
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
edit_btn = page.locator("button:has-text('Edit')")
assert edit_btn.is_visible()
def test_post_edit_modal(alice):
page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").click()
page.fill("#post-content", "Post for edit modal test")
page.fill("#post-title", "Edit Modal Test")
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
page.click("button:has-text('Edit')")
assert page.is_visible("h3:has-text('Edit Post')")
def test_post_edit_submit(alice):
page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").click()
page.fill("#post-content", "Original content for editing test")
page.fill("#post-title", "Original Title")
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
page.click("button:has-text('Edit')")
page.fill("#edit-title", "Edited Title")
page.fill("#edit-content", "Edited content for the post")
page.click("button:has-text('Save Changes')")
expect(page.locator(".post-detail-title:has-text('Edited Title')")).to_be_visible()
def test_post_page_has_structured_data(page, app_server):
page.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
page.fill("#username", "seo_schema_test")
page.fill("#email", "seo_schema@test.dev")
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.click("button:has-text('Create account')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
page.locator(".feed-fab").click()
page.fill(
"#post-content",
"This is a post for SEO testing with structured data schema validation",
)
page.fill("#post-title", "SEO Post For Schema")
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
page.wait_for_url(
f"{BASE_URL}/posts/*", timeout=10000, wait_until="domcontentloaded"
)
scripts = page.locator('script[type="application/ld+json"]')
count = scripts.count()
assert count >= 1
text = scripts.first.text_content()
assert "DiscussionForumPosting" in text or "WebSite" in text
View File
+34
View File
@@ -0,0 +1,34 @@
# retoor <retoor@molodetz.nl>
from tests.conftest import BASE_URL
FOLLOW = "form[action='/follow/bob_test'] button"
UNFOLLOW = "form[action='/follow/unfollow/bob_test'] button"
from playwright.sync_api import expect
def test_follow_then_unfollow(alice):
page, _ = alice
page.goto(f"{BASE_URL}/profile/bob_test", wait_until="domcontentloaded")
if page.is_visible(UNFOLLOW):
page.click(UNFOLLOW)
page.wait_for_url("**/profile/bob_test", wait_until="domcontentloaded")
assert page.is_visible(FOLLOW)
page.click(FOLLOW)
page.wait_for_url("**/profile/bob_test", wait_until="domcontentloaded")
assert page.is_visible(UNFOLLOW)
page.click(UNFOLLOW)
page.wait_for_url("**/profile/bob_test", wait_until="domcontentloaded")
assert page.is_visible(FOLLOW)
def test_profile_viewing_other_user(bob, alice):
page_b, user_b = bob
page_b.goto(f"{BASE_URL}/profile/alice_test", wait_until="domcontentloaded")
assert page_b.is_visible("text=alice_test")
def test_profile_message_button(bob, alice):
page_b, _ = bob
page_b.goto(f"{BASE_URL}/profile/alice_test", wait_until="domcontentloaded")
message_btn = page_b.locator("a:has-text('Send Message')")
assert message_btn.is_visible()
+14
View File
@@ -0,0 +1,14 @@
# retoor <retoor@molodetz.nl>
from playwright.sync_api import expect
from tests.conftest import BASE_URL
def test_profile_regenerate_api_key(alice):
page, user = alice
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
resp = page.request.post(f"{BASE_URL}/profile/regenerate-api-key")
assert resp.status == 200
data = resp.json()
assert "api_key" in data
assert len(data["api_key"]) > 0
+850
View File
@@ -0,0 +1,850 @@
# retoor <retoor@molodetz.nl>
import base64
import time
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
_counter_api_auth = [0]
def _signup_api_auth(password="secret123"):
_counter_api_auth[0] += 1
name = f"apiauth{int(time.time() * 1000)}{_counter_api_auth[0]}"
email = f"{name}@t.dev"
session = requests.Session()
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": email,
"password": password,
"confirm_password": password,
},
allow_redirects=True,
)
return session, name, email, password
def _user_api_auth(name):
return get_table("users").find_one(username=name)
def _key_api_auth(name):
return _user_api_auth(name)["api_key"]
from playwright.sync_api import expect
from devplacepy.database import (
CUSTOMIZATION_GLOBAL_SCOPE,
get_customization_prefs,
get_table,
list_custom_overrides,
set_custom_override,
set_customization_pref,
)
from devplacepy.utils import clear_user_cache
def _user_customization_toggle(username):
return get_table("users").find_one(username=username)
def _seed_global_css(uid, sentinel):
set_custom_override(
"user", uid, CUSTOMIZATION_GLOBAL_SCOPE, "css", f".{sentinel} {{ color: red; }}"
)
def _profile(page, username):
page.goto(f"{BASE_URL}/profile/{username}", wait_until="domcontentloaded")
def _click_toggle(page, category):
page.locator(f"[data-customization-toggle='{category}']").click()
page.locator(".dialog-overlay.visible .dialog-confirm").click()
import re
from uuid import uuid4
from datetime import datetime, timedelta, timezone
from tests.conftest import BASE_URL, assert_share_copies
from devplacepy.utils import make_combined_slug
DPBOT_SIZE = 112147
SOURCE_LENGTH_LIMIT = 400000
def _make_source(length):
unit = "def handler():\n return 0\n"
return (unit * (length // len(unit) + 1))[:length]
def _register_session(prefix):
name = f"{prefix}{int(time.time() * 1000)}"
session = requests.Session()
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return session, name
def _seed_gists(count):
owner = str(uuid4())
get_table("users").insert(
{
"uid": owner,
"username": f"pag_{owner[:8]}",
"email": f"{owner[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
gists = get_table("gists")
for i in range(count):
uid = str(uuid4())
title = f"Pag Gist {i}"
gists.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(title, uid),
"title": title,
"language": "python",
"description": "paginated",
"stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
}
)
return owner
def _set_cm_value(page, value):
page.evaluate(f"""() => {{
const cm = document.querySelector(".CodeMirror")?.CodeMirror;
if (cm) {{
cm.setValue({repr(value)});
cm.save();
}}
}}""")
def _create_gist(
page,
title="Test Gist",
description="Test description",
language="python",
source_code="print('hello')",
):
page.goto(f"{BASE_URL}/gists", wait_until="domcontentloaded")
page.locator("#create-gist-btn").wait_for(state="visible", timeout=10000)
page.locator("#create-gist-btn").click()
page.wait_for_timeout(800)
page.fill("#gist-title", title)
page.fill("#gist-description", description)
page.select_option("#gist-language", language)
page.wait_for_timeout(300)
_set_cm_value(page, source_code)
page.wait_for_timeout(300)
page.locator("button.btn-primary:has-text('Create Gist')").click()
page.wait_for_timeout(2000)
current = page.url
if "/gists/" in current and current != f"{BASE_URL}/gists":
return
page.wait_for_url("**/gists/*", timeout=15000, wait_until="domcontentloaded")
def _seed_searchable_gists(language="python"):
token = uuid4().hex[:10]
owner = str(uuid4())
get_table("users").insert(
{
"uid": owner,
"username": f"gsrch_{owner[:8]}",
"email": f"{owner[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
gists = get_table("gists")
match_title = f"Findable gist {token}"
other_title = f"Unrelated gist {uuid4().hex[:10]}"
for offset, title in ((0, match_title), (1, other_title)):
uid = str(uuid4())
gists.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(title, uid),
"title": title,
"language": language,
"description": f"Description for {title}",
"source_code": "x = 1",
"stars": 0,
"created_at": (base - timedelta(seconds=offset)).isoformat(),
"deleted_at": None,
"deleted_by": None,
}
)
return token, match_title, other_title
import io
import uuid
from PIL import Image
JSON_media = {"Accept": "application/json"}
def _png_bytes_media(color=(200, 30, 30)):
buf = io.BytesIO()
Image.new("RGB", (8, 8), color).save(buf, "PNG")
return buf.getvalue()
def _signup_media(prefix="media"):
s = requests.Session()
name = f"{prefix}_{uuid.uuid4().hex[:10]}"
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@test.devplace",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _login_media(seeded_db, who):
s = requests.Session()
creds = seeded_db[who]
s.post(
f"{BASE_URL}/auth/login",
data={"email": creds["email"], "password": creds["password"]},
allow_redirects=True,
)
return s
def _upload_media(s, name="pic.png", content=None, mime="image/png"):
files = {"file": (name, content if content is not None else _png_bytes_media(), mime)}
r = s.post(f"{BASE_URL}/uploads/upload", files=files)
assert r.status_code == 201, r.text
return r.json()["uid"]
def _create_post_media(s, attachment_uids, title="media post"):
r = s.post(
f"{BASE_URL}/posts/create",
data={
"content": "A post that carries media for the gallery tests.",
"title": title,
"topic": "random",
"attachment_uids": attachment_uids,
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert "/posts/" in r.url, r.url
return r.url
def _create_project_media(s, attachment_uids, title="Media Project"):
r = s.post(
f"{BASE_URL}/projects/create",
data={
"title": title,
"description": "Project with media for gallery tests.",
"project_type": "software",
"platforms": "linux",
"status": "In Development",
"attachment_uids": attachment_uids,
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert "/projects/" in r.url, r.url
return r.url
def _media_json(session, username):
r = session.get(f"{BASE_URL}/profile/{username}?tab=media", headers=JSON_media)
assert r.status_code == 200, r.text[:300]
return r.json()
def _media_uids(session, username):
return [m["uid"] for m in _media_json(session, username)["media"]]
def _seed_media_via_browser(page, title="ui media"):
up = page.request.post(
f"{BASE_URL}/uploads/upload",
multipart={
"file": {
"name": "pic.png",
"mimeType": "image/png",
"buffer": _png_bytes_media(),
}
},
)
assert up.ok, up.text()
uid = up.json()["uid"]
post = page.request.post(
f"{BASE_URL}/posts/create",
form={
"content": "media for the ui gallery test",
"title": title,
"topic": "random",
"attachment_uids": uid,
},
)
assert post.ok, post.text()
return uid
def _media_uids_via_page(page, username):
resp = page.request.get(f"{BASE_URL}/profile/{username}?tab=media", headers=JSON_media)
return [m["uid"] for m in resp.json()["media"]]
from devplacepy.database import (
get_notification_default,
get_notification_prefs,
get_table,
notification_enabled,
reset_notification_prefs,
set_notification_default,
set_notification_pref,
)
from devplacepy.utils import create_notification, generate_uid
def _user_notification_prefs(username):
return get_table("users").find_one(username=username)
def _pref(prefs, key):
return next(p for p in prefs if p["key"] == key)
def _seed_news_seo():
from datetime import datetime, timezone
from devplacepy.database import get_table
from devplacepy.utils import generate_uid, make_combined_slug
uid = generate_uid()
title = f"SEO Test News Article {uid.split('-')[-1]}"
slug = make_combined_slug(title, uid)
get_table("news").insert(
{
"uid": uid,
"slug": slug,
"title": title,
"description": "A seeded news article for SEO tests.",
"content": "Body content for the seeded article.",
"url": "https://example.com/article",
"source_name": "ExampleSource",
"status": "published",
"synced_at": datetime.now(timezone.utc).isoformat(),
"show_on_landing": 0,
"grade": 8,
}
)
return slug, uid
def _seed_owner():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
uid = str(uuid4())
get_table("users").insert(
{
"uid": uid,
"username": f"seo_{uid[:8]}",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def _seed_post_seo(image=None):
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Post", uid)
get_table("posts").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Post",
"content": "Body text for the SEO detail post.",
"topic": "general",
"project_uid": None,
"image": image,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_gist():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Gist", uid)
get_table("gists").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Gist",
"description": "Gist description for SEO tests.",
"source_code": "print('seo')",
"language": "python",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_project():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Project", uid)
get_table("projects").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Project",
"description": "Project description for SEO tests.",
"project_type": "software",
"platforms": "Linux",
"status": "Released",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_news_image(news_uid):
from devplacepy.database import get_table
get_table("news_images").insert(
{
"news_uid": news_uid,
"url": "https://example.com/seo-news-image.jpg",
}
)
def _seed_feed_posts(count):
from datetime import datetime, timezone, timedelta
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
topic = f"seopag{owner[:8]}"
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
posts = get_table("posts")
for i in range(count):
uid = str(uuid4())
posts.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(f"seo pag {i}", uid),
"title": None,
"content": f"seo pag post {i}",
"topic": topic,
"project_uid": None,
"image": None,
"stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
}
)
return topic
def test_admin_sees_other_users_key(alice):
page, _ = alice
_, member, _, _ = _signup_api_auth()
page.goto(f"{BASE_URL}/profile/{member}", wait_until="domcontentloaded")
assert page.locator("[data-api-key-card]").count() == 1
assert _key_api_auth(member) in page.content()
def test_non_owner_non_admin_hidden_and_guarded(bob):
page, user = bob
member = _user_customization_toggle(user["username"])
get_table("users").update({"uid": member["uid"], "role": "Member"}, ["uid"])
clear_user_cache(member["uid"])
target = _user_customization_toggle("alice_test")
_profile(page, target["username"])
assert page.locator("[data-customization]").count() == 0
response = requests.post(
f"{BASE_URL}/profile/{target['username']}/customization/global",
data={"value": "1"},
headers={"Accept": "application/json", "X-API-KEY": member["api_key"]},
allow_redirects=False,
)
assert response.status_code == 403
def test_profile_gists_tab(alice, app_server):
page, alice_user = alice
title = f"Profile Tab Test {int(time.time())}"
_create_gist(page, title=title, source_code="profile_tab = True")
page.goto(
f"{BASE_URL}/profile/{alice_user['username']}?tab=gists",
wait_until="domcontentloaded",
)
assert page.is_visible(f"text={title}")
def test_profile_rank_stat(alice):
page, user = alice
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
assert page.is_visible("text=Rank")
def test_ui_media_tab_link_visible(alice):
page, user = alice
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
assert page.locator(".profile-tab:has-text('Media')").is_visible()
def test_ui_media_tab_shows_thumbnail(alice):
page, user = alice
_seed_media_via_browser(page)
page.goto(
f"{BASE_URL}/profile/{user['username']}?tab=media",
wait_until="domcontentloaded",
)
assert page.locator(".profile-tab.active:has-text('Media')").is_visible()
thumb = page.locator(".media-grid img.gallery-thumb[data-lightbox]").first
thumb.wait_for(state="visible")
assert thumb.get_attribute("data-full")
def test_ui_media_lightbox_opens(alice):
page, user = alice
_seed_media_via_browser(page)
page.goto(
f"{BASE_URL}/profile/{user['username']}?tab=media",
wait_until="domcontentloaded",
)
thumb = page.locator(".media-grid img.gallery-thumb[data-lightbox]").first
thumb.wait_for(state="visible")
thumb.click()
overlay = page.locator(".lightbox-overlay.visible")
overlay.wait_for(state="visible")
assert page.locator(".lightbox-overlay.visible .lightbox-image").is_visible()
page.locator(".lightbox-overlay.visible .lightbox-close").click()
overlay.wait_for(state="hidden")
def test_ui_owner_delete_removes_tile(alice):
page, user = alice
uid = _seed_media_via_browser(page, title="to delete")
page.goto(
f"{BASE_URL}/profile/{user['username']}?tab=media",
wait_until="domcontentloaded",
)
tile = page.locator(f".media-tile[data-media-tile='{uid}']")
tile.wait_for(state="attached")
page.locator(f".media-tile[data-media-tile='{uid}'] .media-delete-btn").click()
page.locator(".dialog-overlay.visible .dialog-confirm").click()
tile.wait_for(state="detached")
assert page.locator(f".media-tile[data-media-tile='{uid}']").count() == 0
def test_ui_member_sees_no_delete_button_on_other_profile(bob, alice):
# alice (admin) seeds media on her own profile
page_a, user_a = alice
_seed_media_via_browser(page_a)
# bob (member) views alice's gallery: no delete controls
page_b, _ = bob
page_b.goto(
f"{BASE_URL}/profile/{user_a['username']}?tab=media",
wait_until="domcontentloaded",
)
page_b.locator(".media-grid img.gallery-thumb").first.wait_for(state="visible")
assert page_b.locator(".media-delete-btn").count() == 0
def test_ui_mobile_media_grid(mobile_page):
page, user = mobile_page
_seed_media_via_browser(page)
page.goto(
f"{BASE_URL}/profile/{user['username']}?tab=media",
wait_until="domcontentloaded",
)
grid = page.locator(".media-grid")
grid.wait_for(state="visible")
assert page.locator(".media-grid .media-tile").count() >= 1
def test_profile_tab_renders_for_owner(bob):
page, user = bob
page.goto(
f"{BASE_URL}/profile/{user['username']}?tab=notifications",
wait_until="domcontentloaded",
)
expect(page.locator("[data-notif-prefs]")).to_be_visible()
expect(
page.locator("[data-notif-toggle][data-type='vote'][data-channel='push']")
).to_have_count(1)
assert page.is_visible("text=Direct messages")
def test_owner_toggle_via_http_persists(bob):
_, user = bob
target = _user_notification_prefs(user["username"])
reset_notification_prefs(target["uid"])
try:
response = requests.post(
f"{BASE_URL}/profile/{target['username']}/notifications",
data={"notification_type": "mention", "channel": "push", "value": "false"},
headers={"Accept": "application/json", "X-API-KEY": target["api_key"]},
allow_redirects=False,
)
assert response.status_code == 200
assert response.json()["ok"] is True
assert notification_enabled(target["uid"], "mention", "push") is False
assert notification_enabled(target["uid"], "mention", "in_app") is True
finally:
reset_notification_prefs(target["uid"])
def test_non_owner_non_admin_forbidden(bob):
_, user = bob
member = _user_notification_prefs(user["username"])
target = _user_notification_prefs("alice_test")
response = requests.post(
f"{BASE_URL}/profile/{target['username']}/notifications",
data={"notification_type": "vote", "channel": "in_app", "value": "false"},
headers={"Accept": "application/json", "X-API-KEY": member["api_key"]},
allow_redirects=False,
)
assert response.status_code == 403
def test_admin_edits_other_user(alice):
_, admin_user = alice
admin = _user_notification_prefs(admin_user["username"])
target = _user_notification_prefs("bob_test")
reset_notification_prefs(target["uid"])
try:
response = requests.post(
f"{BASE_URL}/profile/{target['username']}/notifications",
data={"notification_type": "comment", "channel": "push", "value": "false"},
headers={"Accept": "application/json", "X-API-KEY": admin["api_key"]},
allow_redirects=False,
)
assert response.status_code == 200
assert notification_enabled(target["uid"], "comment", "push") is False
finally:
reset_notification_prefs(target["uid"])
def test_profile_search_returns_results(alice):
page, _ = alice
resp = page.request.get(f"{BASE_URL}/profile/search?q=bob")
assert resp.status == 200
data = resp.json()
assert any("bob" in u["username"] for u in data["results"])
def test_profile_page_loads(alice):
page, user = alice
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
assert page.is_visible(f"text={user['username']}")
def test_profile_stats(alice):
page, user = alice
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
assert page.is_visible("text=Posts")
assert page.is_visible("text=Level")
assert page.is_visible("text=Stars")
def test_profile_level_bar(alice):
page, user = alice
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
assert page.is_visible("text=Progress to next level")
level_bar = page.locator(".profile-level-bar .bar")
assert level_bar.is_visible()
def test_profile_badges(alice):
page, user = alice
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
badges = page.locator(".profile-badge")
assert badges.count() >= 1
def test_profile_tabs(alice):
page, user = alice
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
tabs = ["Posts", "Projects", "Activity"]
for tab in tabs:
assert page.is_visible(f"a:has-text('{tab}')")
def test_profile_posts_tab(alice):
page, user = alice
page.goto(
f"{BASE_URL}/profile/{user['username']}?tab=posts",
wait_until="domcontentloaded",
)
assert page.locator(".profile-tab.active:has-text('Posts')").is_visible()
assert (
page.locator(".profile-posts .post-card").count() > 0
or page.locator(".profile-posts .empty-state:has-text('No posts yet.')").count()
> 0
)
def test_profile_projects_tab_content(alice):
page, user = alice
page.goto(
f"{BASE_URL}/profile/{user['username']}?tab=projects",
wait_until="domcontentloaded",
)
assert page.locator(".profile-tab.active:has-text('Projects')").is_visible()
assert (
page.locator(".profile-posts .project-card").count() > 0
or page.locator(
".profile-posts .empty-state:has-text('No projects yet.')"
).count()
> 0
)
def test_profile_info_section(alice):
page, user = alice
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
assert page.is_visible("text=Bio")
assert page.is_visible("text=Location")
def test_profile_back_link(alice):
page, user = alice
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
back = page.locator("a:has-text('Back')").first
assert back.is_visible()
back.click()
page.wait_for_url(f"{BASE_URL}/feed", wait_until="domcontentloaded")
def test_profile_role_display(alice):
# alice_test is the first user -> Admin; only admins see role badges.
page, user = alice
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
assert page.locator(".profile-role").is_visible()
def test_profile_avatar(alice):
page, user = alice
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
avatar = page.locator(".avatar-lg")
assert avatar.is_visible()
def test_profile_edit_bio(alice):
page, user = alice
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
page.evaluate("""
document.querySelector('textarea[name="bio"]').value = 'Test bio for testing';
document.querySelector('form[action="/profile/update"]').submit();
""")
page.wait_for_url(
f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded"
)
page.locator("text=Test bio for testing").first.wait_for(state="visible")
def test_profile_edit_location(alice):
page, user = alice
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
page.evaluate("""
document.querySelector('input[name="location"]').value = 'Amsterdam';
document.querySelector('form[action="/profile/update"]').submit();
""")
page.wait_for_url(
f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded"
)
page.locator("text=Amsterdam").first.wait_for(state="visible")
def test_profile_edit_all_fields(alice):
page, user = alice
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
page.evaluate("""
document.querySelector('textarea[name="bio"]').value = 'Full stack dev';
document.querySelector('input[name="location"]').value = 'London, UK';
document.querySelector('input[name="git_link"]').value = 'https://git.example.com/alice';
document.querySelector('input[name="website"]').value = 'https://alice.example.com';
document.querySelector('form[action="/profile/update"]').submit();
""")
page.wait_for_url(
f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded"
)
page.locator("text=Full stack dev").first.wait_for(state="visible")
assert page.is_visible("text=London, UK")
assert page.is_visible("text=https://git.example.com/alice")
assert page.is_visible("text=https://alice.example.com")
def test_profile_message_button_self(alice):
page, user = alice
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
message_btn = page.locator("a:has-text('Send Message')")
assert not message_btn.is_visible()
def test_profile_activity_tab(alice):
page, user = alice
page.goto(
f"{BASE_URL}/profile/{user['username']}?tab=activity",
wait_until="domcontentloaded",
)
assert page.locator(".profile-tab.active:has-text('Activity')").is_visible()
assert (
page.locator(".profile-posts .activity-card").count() > 0
or page.locator(
".profile-posts .empty-state:has-text('No activity yet.')"
).count()
> 0
)
def test_profile_followers_json(alice):
page, user = alice
resp = page.request.get(f"{BASE_URL}/profile/{user['username']}/followers")
assert resp.status == 200
data = resp.json()
assert data["username"] == user["username"]
assert data["mode"] == "followers"
assert "followers" in data
def test_profile_following_json(alice):
page, user = alice
resp = page.request.get(f"{BASE_URL}/profile/{user['username']}/following")
assert resp.status == 200
data = resp.json()
assert data["username"] == user["username"]
assert data["mode"] == "following"
assert "following" in data
def test_profile_customization_global_toggle(alice):
page, user = alice
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
form = page.locator(f"form[action='/profile/{user['username']}/customization/global']")
if form.count() > 0:
button = form.locator("button[type='submit']")
expect(button).to_have_text("Disable")
button.click()
page.locator(".dialog-overlay.visible .dialog-confirm").click()
expect(button).to_have_text("Enable")
button.click()
page.locator(".dialog-overlay.visible .dialog-confirm").click()
expect(button).to_have_text("Disable")
def test_profile_customization_pagetype_toggle(alice):
page, user = alice
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
form = page.locator(f"form[action='/profile/{user['username']}/customization/pagetype']")
if form.count() > 0:
button = form.locator("button[type='submit']")
expect(button).to_have_text("Disable")
button.click()
page.locator(".dialog-overlay.visible .dialog-confirm").click()
expect(button).to_have_text("Enable")
button.click()
page.locator(".dialog-overlay.visible .dialog-confirm").click()
expect(button).to_have_text("Disable")
def test_profile_page_has_structured_data(alice):
page, user = alice
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
scripts = page.locator('script[type="application/ld+json"]')
count = scripts.count()
assert count >= 1
text = scripts.first.text_content()
assert "ProfilePage" in text or "WebSite" in text
View File
+451
View File
@@ -0,0 +1,451 @@
# retoor <retoor@molodetz.nl>
import time
import requests
from playwright.sync_api import expect
from tests.conftest import BASE_URL
from devplacepy.database import get_table
_counter_project_files = [0]
def _signup_project_files():
_counter_project_files[0] += 1
name = f"pf{int(time.time() * 1000)}{_counter_project_files[0]}"
session = requests.Session()
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
key = get_table("users").find_one(username=name)["api_key"]
return name, key
def _h_project_files(key):
return {"X-API-KEY": key, "Accept": "application/json"}
def _create_project_project_files(key, title):
r = requests.post(
f"{BASE_URL}/projects/create",
headers=_h_project_files(key),
data={
"title": title,
"description": "filesystem test",
"project_type": "software",
"status": "In Development",
},
)
assert r.status_code == 200, r.text
return r.json()["data"]
def _write_project_files(key, slug, path, content):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/write",
headers=_h_project_files(key),
data={"path": path, "content": content},
allow_redirects=False,
)
def _mkdir(key, slug, path):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/mkdir",
headers=_h_project_files(key),
data={"path": path},
allow_redirects=False,
)
def _move(key, slug, from_path, to_path):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/move",
headers=_h_project_files(key),
data={"from_path": from_path, "to_path": to_path},
allow_redirects=False,
)
def _delete(key, slug, path):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/delete",
headers=_h_project_files(key),
data={"path": path},
allow_redirects=False,
)
def _list(slug, key=None):
return requests.get(
f"{BASE_URL}/projects/{slug}/files",
headers=_h_project_files(key) if key else {"Accept": "application/json"},
)
def _raw_project_files(slug, path, key=None):
return requests.get(
f"{BASE_URL}/projects/{slug}/files/raw",
params={"path": path},
headers=_h_project_files(key) if key else {"Accept": "application/json"},
)
def _paths(slug, key=None):
return sorted(f["path"] for f in _list(slug, key).json()["files"])
def _make_project_ui(page, title):
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.locator("#create-project-btn").click()
page.fill("#title", title)
page.fill("#description", "Project for filesystem UI test")
page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
return page.url
def _open_files(page, title):
proj_url = _make_project_ui(page, title)
slug = proj_url.rstrip("/").split("/")[-1]
page.goto(proj_url + "/files", wait_until="domcontentloaded")
return slug
def _dialog_fill(page, value):
page.locator(".dialog-overlay.visible .dialog-input").wait_for(state="visible")
page.fill(".dialog-overlay.visible .dialog-input", value)
page.click(".dialog-overlay.visible .dialog-confirm")
def _dialog_confirm(page):
page.locator(".dialog-overlay.visible .dialog-confirm").wait_for(state="visible")
page.click(".dialog-overlay.visible .dialog-confirm")
def _dialog_cancel(page):
page.locator(".dialog-overlay.visible .dialog-cancel").wait_for(state="visible")
page.click(".dialog-overlay.visible .dialog-cancel")
def _new_folder(page, name):
page.click("#pf-new-folder")
_dialog_fill(page, name)
page.wait_for_selector(f".pf-node-row:has-text('{name.split('/')[-1]}')")
def _new_file(page, name):
page.click("#pf-new-file")
_dialog_fill(page, name)
def _row(page, name):
return page.locator(f".pf-node-row:has-text('{name}')").first
def _alice_key():
return get_table("users").find_one(username="alice_test")["api_key"]
def test_files_link_on_detail(alice):
page, _ = alice
_make_project_ui(page, "UI Files Link")
link = page.locator("a:has-text('Files')")
expect(link).to_be_visible()
link.click()
page.wait_for_url("**/files", wait_until="domcontentloaded")
expect(page.locator("#pf-tree")).to_be_visible()
def test_files_page_owner_toolbar(alice):
page, _ = alice
_make_project_ui(page, "UI Owner Toolbar")
page.goto(page.url + "/files", wait_until="domcontentloaded")
for sel in ("#pf-new-file", "#pf-new-folder", "#pf-upload", "#pf-save"):
expect(page.locator(sel)).to_be_visible()
def test_create_file_via_ui(alice):
page, _ = alice
_make_project_ui(page, "UI Create File")
page.goto(page.url + "/files", wait_until="domcontentloaded")
_new_file(page, "src/hello.py")
expect(page.locator(".pf-node-row:has-text('hello.py')")).to_be_visible()
expect(page.locator(".pf-node-row:has-text('src')")).to_be_visible()
def test_edit_and_save_via_ui(alice):
page, _ = alice
proj_url = _make_project_ui(page, "UI Edit Save")
slug = proj_url.rstrip("/").split("/")[-1]
page.goto(proj_url + "/files", wait_until="domcontentloaded")
_new_file(page, "main.py")
page.locator(".pf-node-row:has-text('main.py')").click()
page.wait_for_function(
"window.app && window.app.projectFiles && window.app.projectFiles.currentPath === 'main.py'"
)
page.wait_for_timeout(300)
page.evaluate("window.app.projectFiles.editor.setValue('print(42)')")
page.click("#pf-save")
expect(page.locator("#pf-status")).to_have_text("Saved", timeout=4000)
page.wait_for_timeout(300)
assert _raw_project_files(slug, "main.py").json()["content"] == "print(42)"
def test_create_folder_via_ui(alice):
page, _ = alice
_make_project_ui(page, "UI Create Folder")
page.goto(page.url + "/files", wait_until="domcontentloaded")
_new_folder(page, "components")
expect(page.locator(".pf-node-row:has-text('components')")).to_be_visible()
def test_upload_via_ui(alice):
page, _ = alice
_make_project_ui(page, "UI Upload")
page.goto(page.url + "/files", wait_until="domcontentloaded")
with page.expect_file_chooser() as fc:
page.click("#pf-upload")
fc.value.set_files(
[
{
"name": "data.json",
"mimeType": "application/json",
"buffer": b'{"a":1}',
}
]
)
expect(page.locator(".pf-node-row:has-text('data.json')")).to_be_visible()
def test_delete_via_ui(alice):
page, _ = alice
_make_project_ui(page, "UI Delete")
page.goto(page.url + "/files", wait_until="domcontentloaded")
_new_file(page, "trash.txt")
page.locator(".pf-node-row:has-text('trash.txt')").click()
page.click("#pf-delete")
_dialog_confirm(page)
expect(page.locator(".pf-node-row:has-text('trash.txt')")).to_have_count(0)
def test_non_owner_readonly_ui(alice, bob):
alice_page, _ = alice
proj_url = _make_project_ui(alice_page, "UI Readonly")
alice_page.goto(proj_url + "/files", wait_until="domcontentloaded")
_new_file(alice_page, "shared.py")
expect(alice_page.locator(".pf-node-row:has-text('shared.py')")).to_be_visible()
bob_page, _ = bob
bob_page.goto(proj_url + "/files", wait_until="domcontentloaded")
expect(bob_page.locator(".pf-node-row:has-text('shared.py')")).to_be_visible()
expect(bob_page.locator("#pf-new-file")).to_have_count(0)
expect(bob_page.locator("#pf-save")).to_have_count(0)
def test_add_file_into_selected_directory(alice):
page, _ = alice
slug = _open_files(page, "UI DirAdd File")
_new_folder(page, "src")
page.locator(".pf-node-row:has-text('src')").click()
_new_file(page, "main.py")
expect(page.locator(".pf-node-row:has-text('main.py')")).to_be_visible()
page.wait_for_timeout(300)
assert _raw_project_files(slug, "src/main.py").status_code == 200
assert _raw_project_files(slug, "main.py").status_code == 404
def test_add_folder_into_selected_directory(alice):
page, _ = alice
slug = _open_files(page, "UI DirAdd Folder")
_new_folder(page, "src")
page.locator(".pf-node-row:has-text('src')").click()
_new_folder(page, "components")
expect(page.locator(".pf-node-row:has-text('components')")).to_be_visible()
page.wait_for_timeout(300)
assert "src/components" in _paths(slug)
def test_upload_into_selected_directory(alice):
page, _ = alice
slug = _open_files(page, "UI DirAdd Upload")
_new_folder(page, "assets")
page.locator(".pf-node-row:has-text('assets')").click()
with page.expect_file_chooser() as fc:
page.click("#pf-upload")
fc.value.set_files(
[
{
"name": "config.json",
"mimeType": "application/json",
"buffer": b'{"k":1}',
}
]
)
page.wait_for_timeout(400)
assert _raw_project_files(slug, "assets/config.json").status_code == 200
def test_add_file_into_nested_selected_directory(alice):
page, _ = alice
slug = _open_files(page, "UI DirAdd Nested")
_new_folder(page, "src/api")
page.locator(".pf-node-row:has-text('api')").click()
_new_file(page, "routes.py")
page.wait_for_timeout(300)
assert _raw_project_files(slug, "src/api/routes.py").status_code == 200
def test_new_folder_then_add_file_inside_without_reselect(alice):
page, _ = alice
slug = _open_files(page, "UI DirAdd Chained")
_new_folder(page, "lib")
_new_file(page, "index.js")
page.wait_for_timeout(300)
assert _raw_project_files(slug, "lib/index.js").status_code == 200
def test_add_file_uses_parent_of_selected_file(alice):
page, _ = alice
slug = _open_files(page, "UI DirAdd Sibling")
_new_folder(page, "src")
page.locator(".pf-node-row:has-text('src')").click()
_new_file(page, "a.py")
page.locator(".pf-node-row:has-text('a.py')").click()
_new_file(page, "b.py")
page.wait_for_timeout(300)
assert _raw_project_files(slug, "src/a.py").status_code == 200
assert _raw_project_files(slug, "src/b.py").status_code == 200
def test_add_file_at_root_when_nothing_selected(alice):
page, _ = alice
slug = _open_files(page, "UI DirAdd Root")
_new_file(page, "top.py")
page.wait_for_timeout(300)
assert _raw_project_files(slug, "top.py").status_code == 200
def test_context_menu_opens_on_right_click(alice):
page, _ = alice
_open_files(page, "UI CtxOpen")
_new_file(page, "a.py")
_row(page, "a.py").click(button="right")
menu = page.locator(".context-menu.visible")
expect(menu).to_be_visible()
expect(menu).to_contain_text("Rename")
expect(menu).to_contain_text("Delete")
def test_context_menu_closes_on_outside_click(alice):
page, _ = alice
_open_files(page, "UI CtxClose")
_new_file(page, "a.py")
_row(page, "a.py").click(button="right")
expect(page.locator(".context-menu.visible")).to_be_visible()
page.locator("#pf-current").click()
expect(page.locator(".context-menu.visible")).to_have_count(0)
def test_context_menu_rename(alice):
page, _ = alice
slug = _open_files(page, "UI CtxRename")
_new_file(page, "old.py")
_row(page, "old.py").click(button="right")
page.locator(".context-menu.visible .context-menu-item:has-text('Rename')").click()
_dialog_fill(page, "renamed.py")
page.wait_for_timeout(300)
assert _raw_project_files(slug, "renamed.py").status_code == 200
assert _raw_project_files(slug, "old.py").status_code == 404
def test_context_menu_delete_confirm(alice):
page, _ = alice
slug = _open_files(page, "UI CtxDelete")
_new_file(page, "gone.py")
_row(page, "gone.py").click(button="right")
page.locator(".context-menu.visible .context-menu-item:has-text('Delete')").click()
_dialog_confirm(page)
page.wait_for_timeout(300)
assert _raw_project_files(slug, "gone.py").status_code == 404
def test_context_menu_delete_cancel_keeps_file(alice):
page, _ = alice
slug = _open_files(page, "UI CtxDeleteCancel")
_new_file(page, "stay.py")
_row(page, "stay.py").click(button="right")
page.locator(".context-menu.visible .context-menu-item:has-text('Delete')").click()
_dialog_cancel(page)
page.wait_for_timeout(200)
assert _raw_project_files(slug, "stay.py").status_code == 200
def test_context_menu_new_file_in_directory(alice):
page, _ = alice
slug = _open_files(page, "UI CtxNewInDir")
_new_folder(page, "src")
_row(page, "src").click(button="right")
page.locator(
".context-menu.visible .context-menu-item:has-text('New file here')"
).click()
_dialog_fill(page, "inside.py")
page.wait_for_timeout(300)
assert _raw_project_files(slug, "src/inside.py").status_code == 200
def test_multi_select_ctrl_and_delete(alice):
page, _ = alice
slug = _open_files(page, "UI MultiDelete")
_new_file(page, "a.py")
_new_file(page, "b.py")
_new_file(page, "c.py")
_row(page, "a.py").click(modifiers=["Control"])
_row(page, "c.py").click(modifiers=["Control"])
expect(page.locator(".pf-node-row.pf-selected")).to_have_count(2)
_row(page, "c.py").click(button="right")
page.locator(
".context-menu.visible .context-menu-item:has-text('Delete 2 items')"
).click()
_dialog_confirm(page)
page.wait_for_timeout(400)
assert _raw_project_files(slug, "a.py").status_code == 404
assert _raw_project_files(slug, "c.py").status_code == 404
assert _raw_project_files(slug, "b.py").status_code == 200
def test_shift_select_range(alice):
page, _ = alice
_open_files(page, "UI ShiftRange")
_new_file(page, "a.py")
_new_file(page, "b.py")
_new_file(page, "c.py")
_row(page, "a.py").click()
_row(page, "c.py").click(modifiers=["Shift"])
expect(page.locator(".pf-node-row.pf-selected")).to_have_count(3)
def test_drag_file_into_directory(alice):
page, _ = alice
slug = _open_files(page, "UI DragIntoDir")
_new_folder(page, "src")
_new_file(page, "move.py")
_row(page, "move.py").drag_to(_row(page, "src"))
page.wait_for_timeout(400)
assert _raw_project_files(slug, "src/move.py").status_code == 200
assert _raw_project_files(slug, "move.py").status_code == 404
def test_drag_file_to_root(alice):
page, _ = alice
slug = _open_files(page, "UI DragToRoot")
_new_folder(page, "src")
_row(page, "src").click()
_new_file(page, "deep.py")
page.wait_for_timeout(200)
assert _raw_project_files(slug, "src/deep.py").status_code == 200
_row(page, "deep.py").drag_to(page.locator(".pf-root-row"))
page.wait_for_timeout(400)
assert _raw_project_files(slug, "deep.py").status_code == 200
assert _raw_project_files(slug, "src/deep.py").status_code == 404
def test_drag_multi_selection_into_directory(alice):
page, _ = alice
slug = _open_files(page, "UI DragMulti")
_new_folder(page, "dest")
_new_file(page, "x.py")
_new_file(page, "y.py")
_row(page, "x.py").click(modifiers=["Control"])
_row(page, "y.py").click(modifiers=["Control"])
_row(page, "x.py").drag_to(_row(page, "dest"))
page.wait_for_timeout(500)
assert _raw_project_files(slug, "dest/x.py").status_code == 200
assert _raw_project_files(slug, "dest/y.py").status_code == 200
def test_toolbar_delete_uses_custom_dialog(alice):
page, _ = alice
slug = _open_files(page, "UI ToolbarDelete")
_new_file(page, "t.py")
_row(page, "t.py").click()
page.click("#pf-delete")
expect(page.locator(".dialog-overlay.visible")).to_be_visible()
_dialog_confirm(page)
page.wait_for_timeout(300)
assert _raw_project_files(slug, "t.py").status_code == 404
def test_empty_project_shows_hint(alice):
page, _ = alice
_open_files(page, "EmptyHint")
expect(page.locator("#pf-empty")).to_be_visible()
expect(page.locator("#pf-editor-wrap")).to_be_hidden()
expect(page.locator(".pf-empty-msg")).to_contain_text("Create a file")
View File
+75
View File
@@ -0,0 +1,75 @@
# retoor <retoor@molodetz.nl>
from datetime import timedelta
import pytest
import requests
from tests.conftest import BASE_URL, run_async
from devplacepy.database import db, get_table, init_db, refresh_snapshot
from devplacepy import config, project_files
from devplacepy.services.containers import api, store, runtime
from devplacepy.services.containers.backend.base import Mount, PortMapping, RunSpec
from devplacepy.services.containers.backend.docker_cli import build_run_argv, parse_size
from devplacepy.services.containers.backend.fake import FakeBackend
from devplacepy.services.containers.service import ContainerService
from devplacepy.services.containers.api import INSTANCE_LABEL
from devplacepy.services.devii.tasks.schedule import Schedule, now_utc
_CONTAINER_TABLES = (
"instances",
"instance_events",
"instance_metrics",
"instance_schedules",
)
@pytest.fixture(autouse=True)
def _init_db_containers():
init_db()
yield
@pytest.fixture
def env(tmp_path, monkeypatch):
fake = FakeBackend()
runtime.set_backend(fake)
monkeypatch.setattr("devplacepy.config.CONTAINER_WORKSPACES_DIR", tmp_path / "ws")
pid = "ctest-p1"
project = {"uid": pid, "slug": "ctest", "title": "C", "user_uid": "ctest-u1"}
user = {"uid": "ctest-u1", "username": "ctestadmin"}
project_files.write_text_file(pid, user, "app.py", "print(1)\n")
yield {"fake": fake, "project": project, "user": user}
runtime.set_backend(None)
for table in _CONTAINER_TABLES:
if table in db.tables:
for row in [r for r in get_table(table).find()]:
if str(row.get("project_uid", "")).startswith("ctest"):
get_table(table).delete(uid=row["uid"])
for row in list(get_table("project_files").find()):
if str(row.get("project_uid", "")).startswith("ctest"):
get_table("project_files").delete(uid=row["uid"])
def _ready_instance(env, **kwargs):
return run_async(
api.create_instance(env["project"], name=kwargs.pop("name", "inst"), **kwargs)
)
def _promote_admin(username: str) -> None:
users = get_table("users")
user = users.find_one(username=username)
if user:
users.update({"uid": user["uid"], "role": "Admin"}, ["uid"])
def _api_key(username: str) -> str:
refresh_snapshot()
return get_table("users").find_one(username=username)["api_key"]
def test_http_non_admin_forbidden(app_server, page, seeded_db):
project = requests.post(
f"{BASE_URL}/projects/create",
headers={"X-API-KEY": _api_key("bob_test"), "Accept": "application/json"},
data={
"title": "NoAdmin",
"description": "x",
"project_type": "software",
"status": "s",
},
)
slug = project.json()["data"]["slug"] or project.json()["data"]["uid"]
r = requests.get(
f"{BASE_URL}/projects/{slug}/containers/data",
headers={"X-API-KEY": _api_key("bob_test"), "Accept": "application/json"},
)
assert r.status_code == 403
+285
View File
@@ -0,0 +1,285 @@
# retoor <retoor@molodetz.nl>
import time
import requests
from playwright.sync_api import expect
from tests.conftest import BASE_URL
from devplacepy.database import get_table
_counter_project_files = [0]
def _signup_project_files():
_counter_project_files[0] += 1
name = f"pf{int(time.time() * 1000)}{_counter_project_files[0]}"
session = requests.Session()
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
key = get_table("users").find_one(username=name)["api_key"]
return name, key
def _h_project_files(key):
return {"X-API-KEY": key, "Accept": "application/json"}
def _create_project_project_files(key, title):
r = requests.post(
f"{BASE_URL}/projects/create",
headers=_h_project_files(key),
data={
"title": title,
"description": "filesystem test",
"project_type": "software",
"status": "In Development",
},
)
assert r.status_code == 200, r.text
return r.json()["data"]
def _write_project_files(key, slug, path, content):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/write",
headers=_h_project_files(key),
data={"path": path, "content": content},
allow_redirects=False,
)
def _mkdir(key, slug, path):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/mkdir",
headers=_h_project_files(key),
data={"path": path},
allow_redirects=False,
)
def _move(key, slug, from_path, to_path):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/move",
headers=_h_project_files(key),
data={"from_path": from_path, "to_path": to_path},
allow_redirects=False,
)
def _delete(key, slug, path):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/delete",
headers=_h_project_files(key),
data={"path": path},
allow_redirects=False,
)
def _list(slug, key=None):
return requests.get(
f"{BASE_URL}/projects/{slug}/files",
headers=_h_project_files(key) if key else {"Accept": "application/json"},
)
def _raw_project_files(slug, path, key=None):
return requests.get(
f"{BASE_URL}/projects/{slug}/files/raw",
params={"path": path},
headers=_h_project_files(key) if key else {"Accept": "application/json"},
)
def _paths(slug, key=None):
return sorted(f["path"] for f in _list(slug, key).json()["files"])
def _make_project_ui(page, title):
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.locator("#create-project-btn").click()
page.fill("#title", title)
page.fill("#description", "Project for filesystem UI test")
page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
return page.url
def _open_files(page, title):
proj_url = _make_project_ui(page, title)
slug = proj_url.rstrip("/").split("/")[-1]
page.goto(proj_url + "/files", wait_until="domcontentloaded")
return slug
def _dialog_fill(page, value):
page.locator(".dialog-overlay.visible .dialog-input").wait_for(state="visible")
page.fill(".dialog-overlay.visible .dialog-input", value)
page.click(".dialog-overlay.visible .dialog-confirm")
def _dialog_confirm(page):
page.locator(".dialog-overlay.visible .dialog-confirm").wait_for(state="visible")
page.click(".dialog-overlay.visible .dialog-confirm")
def _dialog_cancel(page):
page.locator(".dialog-overlay.visible .dialog-cancel").wait_for(state="visible")
page.click(".dialog-overlay.visible .dialog-cancel")
def _new_folder(page, name):
page.click("#pf-new-folder")
_dialog_fill(page, name)
page.wait_for_selector(f".pf-node-row:has-text('{name.split('/')[-1]}')")
def _new_file(page, name):
page.click("#pf-new-file")
_dialog_fill(page, name)
def _row(page, name):
return page.locator(f".pf-node-row:has-text('{name}')").first
def _alice_key():
return get_table("users").find_one(username="alice_test")["api_key"]
import asyncio
import io
import json
import zipfile
from pathlib import Path
from devplacepy.database import get_table, refresh_snapshot
from devplacepy.services.jobs import queue
from devplacepy.services.jobs.zip_service import ZipService
from tests.conftest import run_async
_counter_zip_download = [0]
def _signup_zip_download():
_counter_zip_download[0] += 1
name = f"zd{int(time.time() * 1000)}{_counter_zip_download[0]}"
session = requests.Session()
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
refresh_snapshot()
key = get_table("users").find_one(username=name)["api_key"]
return name, key
def _h_zip_download(key):
return {"X-API-KEY": key, "Accept": "application/json"}
def _create_project_zip_download(key, title):
r = requests.post(
f"{BASE_URL}/projects/create",
headers=_h_zip_download(key),
data={
"title": title,
"description": "zip download test",
"project_type": "software",
"status": "In Development",
},
)
assert r.status_code == 200, r.text
return r.json()["data"]
def _write_zip_download(key, slug, path, content):
r = requests.post(
f"{BASE_URL}/projects/{slug}/files/write",
headers=_h_zip_download(key),
data={"path": path, "content": content},
allow_redirects=False,
)
assert r.status_code in (200, 302), r.text
def _process_uid(uid):
async def drive():
svc = ZipService()
for _ in range(400):
await svc.run_once()
refresh_snapshot()
job = queue.get_job(uid)
if job and job["status"] in ("done", "failed"):
return
await asyncio.sleep(0.05)
run_async(drive())
def _drain_pending():
async def drive():
svc = ZipService()
appeared = False
for _ in range(600):
refresh_snapshot()
pending = [
r
for r in get_table("jobs").find(kind="zip")
if r["status"] in ("pending", "running")
]
if pending:
appeared = True
await svc.run_once()
refresh_snapshot()
pending = [
r
for r in get_table("jobs").find(kind="zip")
if r["status"] in ("pending", "running")
]
if appeared and not pending and not svc._inflight:
return
await asyncio.sleep(0.05)
run_async(drive())
def _cleanup_archives():
refresh_snapshot()
jobs = get_table("jobs")
for row in list(jobs.find(kind="zip")):
result = json.loads(row.get("result") or "{}")
local_path = result.get("local_path")
if local_path:
Path(local_path).unlink(missing_ok=True)
jobs.delete(uid=row["uid"])
def _login_zip_download(page, name):
page.goto(f"{BASE_URL}/auth/login", wait_until="domcontentloaded")
page.fill("#email", f"{name}@t.dev")
page.fill("#password", "secret123")
page.click("button:has-text('Sign in')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
def test_long_press_opens_context_menu(mobile_page):
page, _ = mobile_page
key = get_table("users").find_one(username="alice_test")["api_key"]
project = _create_project_project_files(key, "Mobile LongPress")
slug = project["slug"]
_write_project_files(key, slug, "touch.py", "x = 1")
page.goto(f"{BASE_URL}/projects/{slug}/files", wait_until="domcontentloaded")
page.wait_for_selector(".pf-node-row:has-text('touch.py')")
page.eval_on_selector(
".pf-node-row:has-text('touch.py')",
"""el => {
const r = el.getBoundingClientRect();
const t = new Touch({identifier: 1, target: el, clientX: r.left + 10, clientY: r.top + 10});
el.dispatchEvent(new TouchEvent('touchstart', {bubbles: true, cancelable: true, touches: [t], targetTouches: [t], changedTouches: [t]}));
}""",
)
page.wait_for_timeout(700)
expect(page.locator(".context-menu.visible")).to_be_visible()
expect(page.locator(".context-menu.visible")).to_contain_text("Delete")
def test_delete_open_file_opens_next(alice):
page, _ = alice
key = _alice_key()
project = _create_project_project_files(key, "DeleteOpensNext")
slug = project["slug"]
_write_project_files(key, slug, "a.py", "1")
_write_project_files(key, slug, "b.py", "2")
page.goto(f"{BASE_URL}/projects/{slug}/files", wait_until="domcontentloaded")
expect(page.locator("#pf-editor-wrap")).to_be_visible()
_row(page, "a.py").click()
page.click("#pf-delete")
_dialog_confirm(page)
page.wait_for_timeout(400)
assert _raw_project_files(slug, "a.py").status_code == 404
expect(page.locator("#pf-editor-wrap")).to_be_visible()
expect(page.locator("#pf-empty")).to_be_hidden()
_row(page, "b.py").click()
page.click("#pf-delete")
_dialog_confirm(page)
page.wait_for_timeout(400)
expect(page.locator("#pf-empty")).to_be_visible()
def test_files_toolbar_has_download_button(app_server, page):
name, key = _signup_zip_download()
proj = _create_project_zip_download(key, "Files Toolbar UI")
slug = proj["slug"] or proj["uid"]
_write_zip_download(key, slug, "README.md", "# hi")
_login_zip_download(page, name)
page.goto(f"{BASE_URL}/projects/{slug}/files", wait_until="domcontentloaded")
expect(page.locator("button:has-text('Download as zip')")).to_be_visible()
def test_files_context_menu_has_download(app_server, page):
name, key = _signup_zip_download()
proj = _create_project_zip_download(key, "Files Context UI")
slug = proj["slug"] or proj["uid"]
_write_zip_download(key, slug, "README.md", "# hi")
_login_zip_download(page, name)
page.goto(f"{BASE_URL}/projects/{slug}/files", wait_until="domcontentloaded")
row = page.locator(".pf-node-row").first
row.wait_for(state="visible")
row.click(button="right")
expect(
page.locator(".context-menu-item:has-text('Download as zip')").first
).to_be_visible()
+859
View File
@@ -0,0 +1,859 @@
# retoor <retoor@molodetz.nl>
import re
from uuid import uuid4
from datetime import datetime, timedelta, timezone
from playwright.sync_api import expect
from tests.conftest import BASE_URL, assert_share_copies
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
def _seed_posts(count):
suite = uuid4().hex[:8]
topic = f"pag{suite}"
users = get_table("users")
posts = get_table("posts")
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
for i in range(count):
owner = str(uuid4())
users.insert(
{
"uid": owner,
"username": f"pag_{suite}_{i}",
"email": f"{suite}_{i}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
uid = str(uuid4())
content = f"Paginated post {i}"
posts.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(content, uid),
"title": None,
"content": content,
"topic": topic,
"project_uid": None,
"image": None,
"stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
"deleted_at": None,
"deleted_by": None,
}
)
return topic
def _seed_post_with_comments(comment_texts, topic="devlog"):
owner = str(uuid4())
get_table("users").insert(
{
"uid": owner,
"username": f"seed_{owner[:8]}",
"email": f"{owner[:8]}@seed.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
post_uid = str(uuid4())
marker = f"seedpost-{post_uid[:8]}"
get_table("posts").insert(
{
"uid": post_uid,
"user_uid": owner,
"slug": make_combined_slug(marker, post_uid),
"title": None,
"content": marker,
"topic": topic,
"project_uid": None,
"image": None,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
comments = get_table("comments")
base = datetime.now(timezone.utc)
for i, text in enumerate(comment_texts):
comments.insert(
{
"uid": str(uuid4()),
"target_type": "post",
"target_uid": post_uid,
"post_uid": post_uid,
"user_uid": owner,
"content": text,
"parent_uid": None,
"created_at": (base + timedelta(seconds=i)).isoformat(),
}
)
return marker, post_uid
def _create_post_ui(page, content, topic="devlog"):
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
page.locator(".feed-fab").first.click()
page.check(f"input[value='{topic}']")
page.fill("#post-content", content)
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
def _seed_searchable_posts():
token = uuid4().hex[:10]
users = get_table("users")
posts = get_table("posts")
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
match_title = f"Findable post {token}"
other_title = f"Unrelated post {uuid4().hex[:10]}"
for offset, title in ((0, match_title), (1, other_title)):
owner = str(uuid4())
users.insert(
{
"uid": owner,
"username": f"srch_{owner[:8]}",
"email": f"{owner[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
uid = str(uuid4())
posts.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(title, uid),
"title": title,
"content": f"Body for {title}",
"topic": "devlog",
"project_uid": None,
"image": None,
"stars": 0,
"created_at": (base - timedelta(seconds=offset)).isoformat(),
"deleted_at": None,
"deleted_by": None,
}
)
return token, match_title, other_title
import time
import requests
from tests.conftest import BASE_URL
_counter_project_files = [0]
def _signup_project_files():
_counter_project_files[0] += 1
name = f"pf{int(time.time() * 1000)}{_counter_project_files[0]}"
session = requests.Session()
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
key = get_table("users").find_one(username=name)["api_key"]
return name, key
def _h_project_files(key):
return {"X-API-KEY": key, "Accept": "application/json"}
def _create_project_project_files(key, title):
r = requests.post(
f"{BASE_URL}/projects/create",
headers=_h_project_files(key),
data={
"title": title,
"description": "filesystem test",
"project_type": "software",
"status": "In Development",
},
)
assert r.status_code == 200, r.text
return r.json()["data"]
def _write_project_files(key, slug, path, content):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/write",
headers=_h_project_files(key),
data={"path": path, "content": content},
allow_redirects=False,
)
def _mkdir(key, slug, path):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/mkdir",
headers=_h_project_files(key),
data={"path": path},
allow_redirects=False,
)
def _move(key, slug, from_path, to_path):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/move",
headers=_h_project_files(key),
data={"from_path": from_path, "to_path": to_path},
allow_redirects=False,
)
def _delete(key, slug, path):
return requests.post(
f"{BASE_URL}/projects/{slug}/files/delete",
headers=_h_project_files(key),
data={"path": path},
allow_redirects=False,
)
def _list(slug, key=None):
return requests.get(
f"{BASE_URL}/projects/{slug}/files",
headers=_h_project_files(key) if key else {"Accept": "application/json"},
)
def _raw_project_files(slug, path, key=None):
return requests.get(
f"{BASE_URL}/projects/{slug}/files/raw",
params={"path": path},
headers=_h_project_files(key) if key else {"Accept": "application/json"},
)
def _paths(slug, key=None):
return sorted(f["path"] for f in _list(slug, key).json()["files"])
def _make_project_ui(page, title):
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.locator("#create-project-btn").click()
page.fill("#title", title)
page.fill("#description", "Project for filesystem UI test")
page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
return page.url
def _open_files(page, title):
proj_url = _make_project_ui(page, title)
slug = proj_url.rstrip("/").split("/")[-1]
page.goto(proj_url + "/files", wait_until="domcontentloaded")
return slug
def _dialog_fill(page, value):
page.locator(".dialog-overlay.visible .dialog-input").wait_for(state="visible")
page.fill(".dialog-overlay.visible .dialog-input", value)
page.click(".dialog-overlay.visible .dialog-confirm")
def _dialog_confirm(page):
page.locator(".dialog-overlay.visible .dialog-confirm").wait_for(state="visible")
page.click(".dialog-overlay.visible .dialog-confirm")
def _dialog_cancel(page):
page.locator(".dialog-overlay.visible .dialog-cancel").wait_for(state="visible")
page.click(".dialog-overlay.visible .dialog-cancel")
def _new_folder(page, name):
page.click("#pf-new-folder")
_dialog_fill(page, name)
page.wait_for_selector(f".pf-node-row:has-text('{name.split('/')[-1]}')")
def _new_file(page, name):
page.click("#pf-new-file")
_dialog_fill(page, name)
def _row(page, name):
return page.locator(f".pf-node-row:has-text('{name}')").first
def _alice_key():
return get_table("users").find_one(username="alice_test")["api_key"]
def _seed_projects(count):
owner = str(uuid4())
get_table("users").insert(
{
"uid": owner,
"username": f"pag_{owner[:8]}",
"email": f"{owner[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
projects = get_table("projects")
for i in range(count):
uid = str(uuid4())
title = f"Pag Project {i}"
projects.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(title, uid),
"title": title,
"description": "paginated",
"project_type": "software",
"status": "In Development",
"stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
}
)
return owner
def _create_project_projects(page, title):
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.locator("#create-project-btn").click()
page.fill("#title", title)
page.fill("#description", "Project for testing")
page.fill("#release_date", "01/06/2026")
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")
def _open_project_with_delete(page, title):
proj_url = _make_project_ui(page, title)
slug = proj_url.rstrip("/").split("/")[-1]
return proj_url, slug
def _click_delete(page):
page.locator(".project-actions-more").click()
page.locator(".context-menu-item:has-text('Delete')").click()
import asyncio
import io
import json
import zipfile
from pathlib import Path
from devplacepy.database import get_table, refresh_snapshot
from devplacepy.services.jobs import queue
from devplacepy.services.jobs.zip_service import ZipService
from tests.conftest import run_async
_counter_zip_download = [0]
def _signup_zip_download():
_counter_zip_download[0] += 1
name = f"zd{int(time.time() * 1000)}{_counter_zip_download[0]}"
session = requests.Session()
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
refresh_snapshot()
key = get_table("users").find_one(username=name)["api_key"]
return name, key
def _h_zip_download(key):
return {"X-API-KEY": key, "Accept": "application/json"}
def _create_project_zip_download(key, title):
r = requests.post(
f"{BASE_URL}/projects/create",
headers=_h_zip_download(key),
data={
"title": title,
"description": "zip download test",
"project_type": "software",
"status": "In Development",
},
)
assert r.status_code == 200, r.text
return r.json()["data"]
def _write_zip_download(key, slug, path, content):
r = requests.post(
f"{BASE_URL}/projects/{slug}/files/write",
headers=_h_zip_download(key),
data={"path": path, "content": content},
allow_redirects=False,
)
assert r.status_code in (200, 302), r.text
def _process_uid(uid):
async def drive():
svc = ZipService()
for _ in range(400):
await svc.run_once()
refresh_snapshot()
job = queue.get_job(uid)
if job and job["status"] in ("done", "failed"):
return
await asyncio.sleep(0.05)
run_async(drive())
def _drain_pending():
async def drive():
svc = ZipService()
appeared = False
for _ in range(600):
refresh_snapshot()
pending = [
r
for r in get_table("jobs").find(kind="zip")
if r["status"] in ("pending", "running")
]
if pending:
appeared = True
await svc.run_once()
refresh_snapshot()
pending = [
r
for r in get_table("jobs").find(kind="zip")
if r["status"] in ("pending", "running")
]
if appeared and not pending and not svc._inflight:
return
await asyncio.sleep(0.05)
run_async(drive())
def _cleanup_archives():
refresh_snapshot()
jobs = get_table("jobs")
for row in list(jobs.find(kind="zip")):
result = json.loads(row.get("result") or "{}")
local_path = result.get("local_path")
if local_path:
Path(local_path).unlink(missing_ok=True)
jobs.delete(uid=row["uid"])
def _login_zip_download(page, name):
page.goto(f"{BASE_URL}/auth/login", wait_until="domcontentloaded")
page.fill("#email", f"{name}@t.dev")
page.fill("#password", "secret123")
page.click("button:has-text('Sign in')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
def test_feed_topnav_navigation(alice):
page, user = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.click("a:has-text('Projects')")
page.wait_for_url(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.click("a:has-text('Home')")
page.wait_for_url(f"{BASE_URL}/", wait_until="domcontentloaded")
def test_auto_open_file_on_load(alice):
page, _ = alice
key = _alice_key()
project = _create_project_project_files(key, "AutoOpen")
_write_project_files(key, project["slug"], "main.py", "print('hi')")
page.goto(
f"{BASE_URL}/projects/{project['slug']}/files", wait_until="domcontentloaded"
)
expect(page.locator("#pf-editor-wrap")).to_be_visible()
expect(page.locator("#pf-empty")).to_be_hidden()
def test_readme_preferred_on_load(alice):
page, _ = alice
key = _alice_key()
project = _create_project_project_files(key, "ReadmePref")
_write_project_files(key, project["slug"], "main.py", "x = 1")
_write_project_files(key, project["slug"], "README.md", "# Hello Readme")
page.goto(
f"{BASE_URL}/projects/{project['slug']}/files", wait_until="domcontentloaded"
)
page.wait_for_function(
"window.app && window.app.projectFiles && window.app.projectFiles.editor"
)
page.wait_for_timeout(300)
content = page.evaluate("window.app.projectFiles.editor.getValue()")
assert "Hello Readme" in content
def test_folder_click_keeps_editor(alice):
page, _ = alice
key = _alice_key()
project = _create_project_project_files(key, "FolderKeepsEditor")
_write_project_files(key, project["slug"], "src/a.py", "x = 1")
page.goto(
f"{BASE_URL}/projects/{project['slug']}/files", wait_until="domcontentloaded"
)
expect(page.locator("#pf-editor-wrap")).to_be_visible()
_row(page, "src").click()
expect(page.locator("#pf-editor-wrap")).to_be_visible()
expect(page.locator("#pf-empty")).to_be_hidden()
def test_project_vote(alice):
page, _ = alice
_create_project_projects(page, "Votable Project")
star = "form[action*='/votes/project/'] button"
count = "form[action*='/votes/project/'] .vote-count-value"
before = int(page.locator(star).first.inner_text().strip(""))
page.locator(star).first.click()
expect(page.locator(count).first).to_have_text(str(before + 1))
after = int(page.locator(star).first.inner_text().strip(""))
assert after == before + 1
def test_project_voted_state_persists(alice):
page, _ = alice
_create_project_projects(page, "Voted State Project")
star = "form[action*='/votes/project/'] button"
page.locator(star).first.click()
page.wait_for_timeout(500)
page.reload(wait_until="domcontentloaded")
expect(page.locator(star).first).to_have_class(re.compile(r"\bvoted\b"))
def test_delete_own_project(alice):
page, _ = alice
_create_project_projects(page, "Deletable Project XYZ")
proj_url = page.url
page.locator(".project-actions-more").click()
page.locator(".context-menu-item:has-text('Delete')").click()
page.locator(".dialog-overlay.visible .dialog-confirm").click()
page.wait_for_url(f"{BASE_URL}/projects", wait_until="domcontentloaded")
resp = page.goto(proj_url, wait_until="domcontentloaded")
assert resp.status == 404
def test_project_edit_button(alice):
page, _ = alice
_create_project_projects(page, "Editable Project ABC")
page.locator(".project-actions-more").click()
expect(page.locator(".context-menu-item:has-text('Edit')")).to_be_visible()
def test_project_edit_modal_prefills(alice):
page, _ = alice
_create_project_projects(page, "Edit Modal Project")
page.locator(".project-actions-more").click()
page.locator(".context-menu-item:has-text('Edit')").click()
assert page.is_visible("h3:has-text('Edit Project')")
expect(page.locator("#edit-project-title")).to_have_value("Edit Modal Project")
expect(page.locator("#platforms-tags .platform-tag:has-text('PC')")).to_be_visible()
def test_project_edit_submit(alice):
page, _ = alice
_create_project_projects(page, "Original Project Title")
proj_url = page.url
page.locator(".project-actions-more").click()
page.locator(".context-menu-item:has-text('Edit')").click()
page.fill("#edit-project-title", "Edited Project Title")
page.fill("#edit-project-description", "Edited project description body")
page.click("button:has-text('Save Changes')")
expect(
page.locator(".project-detail-title:has-text('Edited Project Title')")
).to_be_visible()
assert page.url.rstrip("/") == proj_url.rstrip("/")
def test_project_edit_status_change(alice):
page, _ = alice
_create_project_projects(page, "Status Change Project")
page.locator(".project-actions-more").click()
page.locator(".context-menu-item:has-text('Edit')").click()
page.check("#edit-project-modal input[value='Released']")
page.click("button:has-text('Save Changes')")
expect(page.locator(".project-status:has-text('Released')")).to_be_visible()
def test_project_edit_hidden_for_non_owner(alice, bob):
page, _ = alice
_create_project_projects(page, "Owner Only Edit Project")
proj_url = page.url
bob_page, _ = bob
bob_page.goto(proj_url, wait_until="domcontentloaded")
bob_page.locator(".project-actions-more").click()
expect(
bob_page.locator(".context-menu-item:has-text('Download zip')")
).to_be_visible()
expect(bob_page.locator(".context-menu-item:has-text('Edit')")).to_have_count(0)
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", "01/06/2026")
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):
page, _ = alice
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
assert page.is_visible("h1:has-text('Projects')")
assert page.is_visible("text=Discover amazing projects")
def test_projects_search_bar(alice):
page, _ = alice
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
search = page.locator("input[placeholder='Search projects...']")
assert search.is_visible()
def test_projects_tabs(alice):
page, _ = alice
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
tabs = ["Recently Released", "Most Popular", "New This Week"]
for tab in tabs:
assert page.is_visible(f"text={tab}")
def test_projects_tab_switch(alice):
page, _ = alice
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.click("a:has-text('Most Popular')")
page.wait_for_url(f"{BASE_URL}/projects?tab=popular", wait_until="domcontentloaded")
page.click("a:has-text('New This Week')")
page.wait_for_url(f"{BASE_URL}/projects?tab=new", wait_until="domcontentloaded")
def test_create_project_button(alice):
page, _ = alice
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
fab = page.locator("#create-project-btn")
assert fab.is_visible()
fab.click()
assert page.is_visible("h3:has-text('Create Project')")
def test_create_project_full(alice):
page, _ = alice
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.locator("#create-project-btn").click()
page.fill("#title", "Playwright Test Game")
page.fill("#description", "A game created by Playwright integration tests")
page.fill("#release_date", "01/06/2026")
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 page.is_visible("text=Playwright Test Game")
def test_create_project_software(alice):
page, _ = alice
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.locator("#create-project-btn").click()
page.fill("#title", "CLI Tool")
page.fill("#description", "A command line tool")
page.check("input[value='software']")
page.check("input[value='Released']")
page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
assert page.is_visible("text=CLI Tool")
def test_create_project_mobile_app(alice):
page, _ = alice
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.locator("#create-project-btn").click()
page.fill("#title", "Mobile Messenger")
page.fill("#description", "A cross-platform messaging app")
page.check("input[value='mobile_app']")
page.locator("#platforms-input").fill("iOS")
page.locator("#platforms-input").press("Enter")
page.locator("#platforms-input").fill("Android")
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 page.is_visible("text=Mobile Messenger")
def test_project_search(alice):
page, _ = alice
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.locator("#create-project-btn").click()
page.fill("#title", "SearchableProject")
page.fill("#description", "Find me via search")
page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.fill("input[placeholder='Search projects...']", "SearchableProject")
page.locator("input[placeholder='Search projects...']").press("Enter")
page.wait_for_timeout(500)
assert page.is_visible("text=SearchableProject")
def test_project_cancel_modal(alice):
page, _ = alice
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.locator("#create-project-btn").click()
cancel = page.locator("button:has-text('Cancel')").first
cancel.click()
modal = page.locator("#create-project-modal")
assert not modal.is_visible()
def test_project_platform_presets(alice):
page, _ = alice
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.locator("#create-project-btn").click()
presets = page.locator(".platform-preset")
assert presets.count() >= 5
presets.first.click()
tag = page.locator("#platforms-tags .platform-tag")
assert tag.is_visible()
def test_project_pagination_first_page(alice):
page, _ = alice
owner = _seed_projects(26)
page.goto(f"{BASE_URL}/projects?user_uid={owner}", wait_until="domcontentloaded")
assert page.locator(".project-card").count() == 25
assert page.is_visible(".load-more-wrap")
def test_project_pagination_load_more(alice):
page, _ = alice
owner = _seed_projects(26)
page.goto(f"{BASE_URL}/projects?user_uid={owner}", wait_until="domcontentloaded")
page.click(".load-more-wrap a")
page.wait_for_url(lambda url: "before=" in url, wait_until="domcontentloaded")
assert f"user_uid={owner}" in page.url
assert page.locator(".project-card").count() == 1
assert not page.is_visible(".load-more-wrap")
def test_project_pagination_count_reflects_total(alice):
page, _ = alice
owner = _seed_projects(26)
page.goto(f"{BASE_URL}/projects?user_uid={owner}", wait_until="domcontentloaded")
count_text = page.text_content(".projects-count")
assert "Showing 25 of 26 projects" in count_text
def test_projects_count(alice):
page, _ = alice
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
count_text = page.text_content(".projects-count")
assert "Showing" in count_text
assert "projects" in count_text
def test_project_detail_page(alice):
page, _ = alice
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.locator("#create-project-btn").click()
page.fill("#title", "DetailTestProject")
page.fill("#description", "Project detail page test")
page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
assert page.is_visible("text=DetailTestProject")
assert page.is_visible("text=Back to Projects")
def test_project_detail_unauth(page):
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
assert page.is_visible("h1:has-text('Projects')")
assert page.locator("#create-project-btn").count() == 0
def test_project_detail_back_link(alice):
page, _ = alice
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.locator("#create-project-btn").click()
page.fill("#title", "BackLinkProject")
page.fill("#description", "Test back link")
page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
back = page.locator("a:has-text('Back to Projects')")
assert back.is_visible()
back.click()
page.wait_for_url(f"{BASE_URL}/projects", wait_until="domcontentloaded")
def test_project_comments_form_visible(alice):
page, _ = alice
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.locator("#create-project-btn").click()
page.fill("#title", "CommentProject")
page.fill("#description", "Project for testing comments")
page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
assert page.is_visible("text=Comments")
assert page.is_visible("text=No comments yet")
assert page.is_visible("textarea[name='content']")
def test_project_comment_create(alice):
page, _ = alice
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.locator("#create-project-btn").click()
page.fill("#title", "CommentCreateProj")
page.fill("#description", "Project for creating a comment")
page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
page.fill("textarea[name='content']", "Great project!")
page.click("button:has-text('Post')")
page.wait_for_timeout(500)
assert page.is_visible("text=Great project!")
def test_project_comment_reply(alice):
page, _ = alice
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.locator("#create-project-btn").click()
page.fill("#title", "CommentReplyProj")
page.fill("#description", "Project for testing reply")
page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
page.fill("textarea[name='content']", "First comment")
page.click("button:has-text('Post')")
page.wait_for_timeout(500)
assert page.is_visible("text=First comment")
page.click("button:has-text('Reply')")
reply_form = page.locator(".comment-reply-form").first
reply_form.locator("textarea[name='content']").fill("Reply to comment")
reply_form.locator("button:has-text('Post')").click()
page.wait_for_timeout(500)
assert page.is_visible("text=Reply to comment")
def test_project_comment_delete(alice):
page, _ = alice
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.locator("#create-project-btn").click()
page.fill("#title", "CommentDeleteProj")
page.fill("#description", "Project for testing delete")
page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
page.fill("textarea[name='content']", "Comment to delete")
page.click("button:has-text('Post')")
page.wait_for_timeout(500)
assert page.is_visible("text=Comment to delete")
page.locator(".comment-action-btn:has-text('Delete')").click()
page.locator(".dialog-overlay.visible .dialog-confirm").click()
page.wait_for_timeout(500)
assert not page.is_visible("text=Comment to delete")
def test_project_make_private(alice):
page, _ = alice
_create_project_projects(page, "PrivateToggleProject")
slug = page.url.rstrip("/").split("/")[-1]
page.locator(".project-actions-more").click()
page.locator(".context-menu-item:has-text('Make private')").click()
page.locator(".dialog-overlay.visible .dialog-confirm").click()
page.wait_for_url(f"**/projects/{slug}", wait_until="domcontentloaded")
expect(page.locator(".badge-type:has-text('Private')")).to_be_visible()
def test_project_make_readonly(alice):
page, _ = alice
_create_project_projects(page, "ReadonlyToggleProject")
slug = page.url.rstrip("/").split("/")[-1]
page.locator(".project-actions-more").click()
page.locator(".context-menu-item:has-text('Make read-only')").click()
page.locator(".dialog-overlay.visible .dialog-confirm").click()
page.wait_for_url(f"**/projects/{slug}", wait_until="domcontentloaded")
expect(page.locator(".badge-type:has-text('Read-only')")).to_be_visible()
def test_data_confirm_accept_proceeds(alice):
page, _ = alice
proj_url, _ = _open_project_with_delete(page, "Confirm Accept")
_click_delete(page)
page.locator(".dialog-overlay.visible .dialog-confirm").click()
page.wait_for_url(f"{BASE_URL}/projects", wait_until="domcontentloaded")
resp = page.goto(proj_url, wait_until="domcontentloaded")
assert resp.status == 404
def test_detail_download_button_downloads(app_server, page):
try:
name, key = _signup_zip_download()
proj = _create_project_zip_download(key, "Detail Download UI")
slug = proj["slug"] or proj["uid"]
_write_zip_download(key, slug, "README.md", "# hi")
_login_zip_download(page, name)
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
page.locator(".project-actions-more").click()
btn = page.locator(".context-menu-item:has-text('Download zip')")
btn.wait_for(state="visible")
with page.expect_download(timeout=30000) as dl_info:
btn.click()
_drain_pending()
assert dl_info.value.suggested_filename.endswith(".zip")
finally:
_cleanup_archives()
+237
View File
@@ -0,0 +1,237 @@
# retoor <retoor@molodetz.nl>
from uuid import uuid4
from datetime import datetime, timezone
from tests.conftest import BASE_URL
from devplacepy.database import get_table
import requests
def _seed_news_seo():
from datetime import datetime, timezone
from devplacepy.database import get_table
from devplacepy.utils import generate_uid, make_combined_slug
uid = generate_uid()
title = f"SEO Test News Article {uid.split('-')[-1]}"
slug = make_combined_slug(title, uid)
get_table("news").insert(
{
"uid": uid,
"slug": slug,
"title": title,
"description": "A seeded news article for SEO tests.",
"content": "Body content for the seeded article.",
"url": "https://example.com/article",
"source_name": "ExampleSource",
"status": "published",
"synced_at": datetime.now(timezone.utc).isoformat(),
"show_on_landing": 0,
"grade": 8,
}
)
return slug, uid
def _seed_owner():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
uid = str(uuid4())
get_table("users").insert(
{
"uid": uid,
"username": f"seo_{uid[:8]}",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def _seed_post_seo(image=None):
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Post", uid)
get_table("posts").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Post",
"content": "Body text for the SEO detail post.",
"topic": "general",
"project_uid": None,
"image": image,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_gist():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Gist", uid)
get_table("gists").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Gist",
"description": "Gist description for SEO tests.",
"source_code": "print('seo')",
"language": "python",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_project():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Project", uid)
get_table("projects").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Project",
"description": "Project description for SEO tests.",
"project_type": "software",
"platforms": "Linux",
"status": "Released",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_news_image(news_uid):
from devplacepy.database import get_table
get_table("news_images").insert(
{
"news_uid": news_uid,
"url": "https://example.com/seo-news-image.jpg",
}
)
def _seed_feed_posts(count):
from datetime import datetime, timezone, timedelta
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
topic = f"seopag{owner[:8]}"
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
posts = get_table("posts")
for i in range(count):
uid = str(uuid4())
posts.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(f"seo pag {i}", uid),
"title": None,
"content": f"seo pag post {i}",
"topic": topic,
"project_uid": None,
"image": None,
"stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
}
)
return topic
def test_landing_page_loads(page, app_server):
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
assert "DevPlace" in page.title()
assert page.is_visible("text=Devplace.net")
assert page.is_visible("text=The Developer Social Network")
def test_landing_tagline(page, app_server):
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
tagline = page.text_content("p")
assert "Track industry shifts" in tagline
def test_landing_join_button(page, app_server):
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
btn = page.locator("text=Join DevPlace Free")
assert btn.is_visible()
assert btn.get_attribute("href") == "/auth/signup"
def test_landing_features(page, app_server):
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
features = [
"100% Free Forever",
"Zero Ads",
"No Censorship",
"No Payments",
]
for feature in features:
assert page.is_visible(f"text={feature}")
def test_landing_nav_links(page, app_server):
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
assert page.is_visible("a:has-text('Home')")
assert page.is_visible("a:has-text('News')")
assert page.is_visible("a:has-text('Projects')")
assert page.is_visible("a:has-text('Login')")
assert page.is_visible("a:has-text('Sign Up')")
def test_landing_footer_links(page, app_server):
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
assert page.is_visible("a:has-text('Bug Report')")
def test_landing_hero_present(page, app_server):
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
assert page.is_visible("text=Log In")
assert page.is_visible("text=Join DevPlace Free")
assert page.is_visible("text=Devplace.net")
def test_landing_news_section(page, app_server):
uid = str(uuid4())
get_table("news").insert(
{
"uid": uid,
"slug": f"landing-news-{uid[:8]}",
"title": f"Landing News {uid[:8]}",
"external_id": f"landing_test_{uid[:8]}",
"grade": 8,
"status": "published",
"show_on_landing": 1,
"source_name": "LandingTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": "Seeded article so the landing news section renders.",
}
)
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
page.locator("text=Developer News").wait_for(state="visible", timeout=5000)
cards = page.locator(".news-card-title a")
assert cards.count() > 0, "No news articles visible on landing page"
def test_landing_page_has_unique_title(page, app_server):
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
title = page.title()
assert title and "DevPlace" in title
View File
+51
View File
@@ -0,0 +1,51 @@
# retoor <retoor@molodetz.nl>
from playwright.sync_api import expect
from tests.conftest import BASE_URL
from tests.e2e.project.files import _make_project_ui
def _open_project_with_delete(page, title):
proj_url = _make_project_ui(page, title)
slug = proj_url.rstrip("/").split("/")[-1]
return proj_url, slug
def _click_delete(page):
page.locator(".project-actions-more").click()
page.locator(".context-menu-item:has-text('Delete')").click()
def test_data_confirm_opens_custom_dialog(alice):
page, _ = alice
_open_project_with_delete(page, "Confirm Dialog Open")
_click_delete(page)
expect(page.locator(".dialog-overlay.visible")).to_be_visible()
expect(page.locator(".dialog-overlay.visible .dialog-message")).to_contain_text(
"Delete"
)
def test_data_confirm_cancel_aborts(alice):
page, _ = alice
proj_url, _ = _open_project_with_delete(page, "Confirm Cancel")
_click_delete(page)
page.locator(".dialog-overlay.visible .dialog-cancel").click()
expect(page.locator(".dialog-overlay")).not_to_be_visible()
resp = page.goto(proj_url, wait_until="domcontentloaded")
assert resp.status == 200
def test_dialog_escape_cancels(alice):
page, _ = alice
_open_project_with_delete(page, "Confirm Escape")
_click_delete(page)
expect(page.locator(".dialog-overlay.visible")).to_be_visible()
page.keyboard.press("Escape")
expect(page.locator(".dialog-overlay")).not_to_be_visible()
def test_dialog_backdrop_click_cancels(alice):
page, _ = alice
_open_project_with_delete(page, "Confirm Backdrop")
_click_delete(page)
overlay = page.locator(".dialog-overlay.visible")
expect(overlay).to_be_visible()
overlay.click(position={"x": 5, "y": 5})
expect(page.locator(".dialog-overlay")).not_to_be_visible()
+37
View File
@@ -0,0 +1,37 @@
# retoor <retoor@molodetz.nl>
from tests.e2e.post import create_post
POST_PAYLOAD = (
"XSSPROBE <img src=x onerror=alert('p')><script>alert('s')</script> end of post"
)
COMMENT_PAYLOAD = (
"CMTPROBE <img src=x onerror=alert('c')><script>alert('s')</script> end of comment"
)
def test_post_content_is_sanitized(alice):
page, _ = alice
fired = []
page.on("dialog", lambda d: (fired.append(d.message), d.dismiss()))
create_post(page, "random", POST_PAYLOAD)
content = page.locator(".post-detail-content")
content.wait_for(state="visible")
page.locator(".post-detail-content p").first.wait_for(state="visible")
assert content.locator("script").count() == 0
assert content.locator("[onerror]").count() == 0
assert not fired, f"XSS payload executed: {fired}"
def test_comment_content_is_sanitized(alice):
page, _ = alice
fired = []
page.on("dialog", lambda d: (fired.append(d.message), d.dismiss()))
create_post(page, "random", "Clean host post for comment sanitization")
page.locator(".comment-form textarea[name='content']").fill(COMMENT_PAYLOAD)
page.locator(".comment-form button:has-text('Post')").click()
comment = page.locator(".comment-text:has-text('CMTPROBE')")
comment.wait_for(state="visible")
page.locator(".comment-text p").first.wait_for(state="visible")
assert comment.locator("script").count() == 0
assert comment.locator("[onerror]").count() == 0
assert not fired, f"XSS payload executed: {fired}"