forked from retoor/devplacepy
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,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
|
||||
Reference in New Issue
Block a user