chore: reorganize test files into domain-specific subdirectories under tests/
Split the monolithic test directory into three tiers (unit, api, e2e) with a path-mirroring directory structure. Added corresponding Makefile targets (test-unit, test-api, test-e2e) and updated all documentation references (CLAUDE.md, README.md, testing-cicd.html, testing-framework.html, testing-make.html) to reflect the new layout and naming conventions.
This commit is contained in:
@@ -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)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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") == "/"
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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".+"))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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}"
|
||||
Reference in New Issue
Block a user