2026-06-13 16:32:33 +02:00
|
|
|
# retoor <retoor@molodetz.nl>
|
|
|
|
|
|
2026-05-29 00:45:07 +02:00
|
|
|
from datetime import datetime, timedelta, timezone
|
2026-05-10 09:08:12 +02:00
|
|
|
from tests.conftest import BASE_URL
|
2026-05-29 00:45:07 +02:00
|
|
|
from devplacepy.database import get_table
|
|
|
|
|
from devplacepy.utils import generate_uid
|
2026-06-13 16:32:33 +02:00
|
|
|
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(
|
|
|
|
|
{
|
2026-06-14 16:46:36 +02:00
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
2026-06-13 16:32:33 +02:00
|
|
|
"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]}",
|
Add the trust and safety subsystem and the App Store compliance work
Implements the moderation and consent obligations a social platform carries,
so the web version and any client that speaks to it enforce the same rules.
Moderation core (services/moderation/, database/moderation.py): a reportable
target registry, the content filter and its choke points, the report queue with
atomic resolution, enforcement actions, consent tracking, maturity gating, and
account deletion with a grace window.
Surfaces: POST /reports plus the member report list, /admin/moderation and the
per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and
account deletion under /profile, the report button and dialog partials, the
maturity gate, and the moderation stylesheet and ReportDialog client.
Every user-generated surface stays reportable by construction: new content tables
are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a
reason, and the registry test fails the suite on anything left unclassified.
Docs: community guidelines, content moderation, intellectual property, privacy,
terms, contact, and the admin-only moderation operations page, plus the
moderation API group and the Devii moderation actions.
Compliance record: applecomp.md is the requirement register, applechanges.md the
gap analysis against this codebase, and appleimpl.md the implementation design
they resolve to.
Tests cover the report flow, admin moderation, consent, account deletion, terms
acceptance, workspaces, and the registry invariant across the unit, api, and e2e
tiers.
2026-08-09 00:18:20 +02:00
|
|
|
"terms_version": "1",
|
2026-06-13 16:32:33 +02:00
|
|
|
"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(
|
|
|
|
|
{
|
2026-06-14 16:46:36 +02:00
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
2026-06-13 16:32:33 +02:00
|
|
|
"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(
|
|
|
|
|
{
|
2026-06-14 16:46:36 +02:00
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
2026-06-13 16:32:33 +02:00
|
|
|
"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(
|
|
|
|
|
{
|
2026-06-14 16:46:36 +02:00
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
2026-06-13 16:32:33 +02:00
|
|
|
"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(
|
|
|
|
|
{
|
2026-06-14 16:46:36 +02:00
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
2026-06-13 16:32:33 +02:00
|
|
|
"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(
|
|
|
|
|
{
|
2026-06-14 16:46:36 +02:00
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
2026-06-13 16:32:33 +02:00
|
|
|
"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
|
2026-05-29 00:45:07 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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):
|
2026-06-09 18:48:08 +02:00
|
|
|
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(),
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-05-29 00:45:07 +02:00
|
|
|
|
|
|
|
|
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()}"
|
2026-06-09 18:48:08 +02:00
|
|
|
assert page.locator(".load-more-wrap").count() == 0, (
|
|
|
|
|
"Load More should be absent on final page"
|
|
|
|
|
)
|
2026-05-29 00:45:07 +02:00
|
|
|
|
|
|
|
|
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):
|
2026-06-09 18:48:08 +02:00
|
|
|
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(),
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-05-29 00:45:07 +02:00
|
|
|
|
|
|
|
|
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"])
|
2026-05-10 09:08:12 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_notifications_page_loads(alice):
|
|
|
|
|
page, _ = alice
|
2026-06-10 05:22:44 +02:00
|
|
|
page.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
|
2026-05-10 09:08:12 +02:00
|
|
|
assert page.is_visible("h2:has-text('Notifications')")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mark_all_read(alice):
|
|
|
|
|
page, _ = alice
|
2026-06-10 05:22:44 +02:00
|
|
|
page.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
|
feat: add api key auth, devii agent, openai gateway, and admin service management
This commit introduces a comprehensive set of new features including API key authentication with CLI management commands (get, reset, backfill), a Devii agentic assistant with WebSocket terminal and session bootstrap, an OpenAI-compatible LLM gateway service, and an admin service management panel. It also adds Playwright browser automation for bot support, configures internal gateway URLs, refactors content editing/deletion to support JSON API responses, and updates documentation across AGENTS.md, README.md, and the developer docs site.
2026-06-08 17:38:33 +02:00
|
|
|
clear = page.locator("form[action='/notifications/mark-all-read'] button")
|
2026-05-12 12:45:52 +02:00
|
|
|
if clear.is_visible():
|
|
|
|
|
clear.click()
|
2026-05-10 09:08:12 +02:00
|
|
|
page.wait_for_timeout(500)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_notifications_navigation(alice):
|
|
|
|
|
page, _ = alice
|
2026-06-10 05:22:44 +02:00
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
page.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
|
2026-05-10 09:08:12 +02:00
|
|
|
assert page.is_visible("h2:has-text('Notifications')")
|
|
|
|
|
|
|
|
|
|
|
2026-05-11 03:14:43 +02:00
|
|
|
def test_notifications_empty_state(alice):
|
|
|
|
|
page, _ = alice
|
2026-05-23 03:21:55 +02:00
|
|
|
page.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
|
2026-06-09 18:48:08 +02:00
|
|
|
assert page.is_visible("h2:has-text('Notifications')") or page.is_visible(
|
|
|
|
|
"text=No notifications yet"
|
|
|
|
|
)
|
2026-05-11 03:14:43 +02:00
|
|
|
|
|
|
|
|
|
2026-05-12 12:45:52 +02:00
|
|
|
def test_notifications_header_has_clear(alice):
|
2026-05-11 03:14:43 +02:00
|
|
|
page, _ = alice
|
2026-06-10 05:22:44 +02:00
|
|
|
page.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
|
feat: add api key auth, devii agent, openai gateway, and admin service management
This commit introduces a comprehensive set of new features including API key authentication with CLI management commands (get, reset, backfill), a Devii agentic assistant with WebSocket terminal and session bootstrap, an OpenAI-compatible LLM gateway service, and an admin service management panel. It also adds Playwright browser automation for bot support, configures internal gateway URLs, refactors content editing/deletion to support JSON API responses, and updates documentation across AGENTS.md, README.md, and the developer docs site.
2026-06-08 17:38:33 +02:00
|
|
|
clear = page.locator("form[action='/notifications/mark-all-read'] button")
|
2026-05-12 12:45:52 +02:00
|
|
|
assert clear.is_visible()
|
2026-05-11 03:14:43 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_notifications_topnav_user(alice):
|
|
|
|
|
page, user = alice
|
2026-06-10 05:22:44 +02:00
|
|
|
page.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
|
2026-05-11 03:14:43 +02:00
|
|
|
assert page.is_visible(f"text={user['username']}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_notifications_avatar_visible(alice):
|
|
|
|
|
page, _ = alice
|
2026-06-10 05:22:44 +02:00
|
|
|
page.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
|
2026-05-11 03:14:43 +02:00
|
|
|
avatar = page.locator("img.avatar-img").first
|
|
|
|
|
assert avatar.is_visible()
|
2026-05-12 12:45:52 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_vote_notification_on_post(app_server, browser, seeded_db):
|
|
|
|
|
from tests.conftest import login_user
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-05-12 12:45:52 +02:00
|
|
|
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()
|
2026-06-09 18:48:08 +02:00
|
|
|
assert "Internal Server Error" not in pa_body, (
|
|
|
|
|
f"Alice got 500 after vote: {pa_body[:300]}"
|
|
|
|
|
)
|
2026-05-12 12:45:52 +02:00
|
|
|
|
|
|
|
|
pb.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
|
|
|
|
|
pb.wait_for_timeout(1500)
|
|
|
|
|
body = pb.locator("body").text_content()
|
2026-06-09 18:48:08 +02:00
|
|
|
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]}"
|
|
|
|
|
)
|
2026-05-12 12:45:52 +02:00
|
|
|
assert "++'d" in body, f"Expected '++\\'d' in notifications, got: {body[:500]}"
|
|
|
|
|
|
|
|
|
|
ctx_a.close()
|
|
|
|
|
ctx_b.close()
|
2026-05-16 02:49:53 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_comment_notification_on_post(app_server, browser, seeded_db):
|
|
|
|
|
from tests.conftest import login_user
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-05-16 02:49:53 +02:00
|
|
|
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()
|
2026-06-09 18:48:08 +02:00
|
|
|
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]}"
|
|
|
|
|
)
|
2026-05-16 02:49:53 +02:00
|
|
|
|
|
|
|
|
ctx_a.close()
|
|
|
|
|
ctx_b.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_follow_notification(app_server, browser, seeded_db):
|
|
|
|
|
from tests.conftest import login_user
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-05-16 02:49:53 +02:00
|
|
|
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)
|
2026-06-10 05:22:44 +02:00
|
|
|
follow_btn = pb.locator("form[action='/follow/alice_test'] button")
|
2026-05-16 02:49:53 +02:00
|
|
|
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()
|
2026-06-09 18:48:08 +02:00
|
|
|
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]}"
|
|
|
|
|
)
|
2026-05-16 02:49:53 +02:00
|
|
|
|
|
|
|
|
ctx_a.close()
|
|
|
|
|
ctx_b.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_message_notification(app_server, browser, seeded_db):
|
|
|
|
|
from tests.conftest import login_user
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-05-16 02:49:53 +02:00
|
|
|
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)
|
|
|
|
|
|
2026-07-23 00:02:43 +02:00
|
|
|
msg_input = pb.locator("textarea[name='content']").first
|
2026-05-16 02:49:53 +02:00
|
|
|
msg_input.wait_for(state="visible", timeout=10000)
|
|
|
|
|
msg_input.fill("Hello from bob_test!")
|
Add the trust and safety subsystem and the App Store compliance work
Implements the moderation and consent obligations a social platform carries,
so the web version and any client that speaks to it enforce the same rules.
Moderation core (services/moderation/, database/moderation.py): a reportable
target registry, the content filter and its choke points, the report queue with
atomic resolution, enforcement actions, consent tracking, maturity gating, and
account deletion with a grace window.
Surfaces: POST /reports plus the member report list, /admin/moderation and the
per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and
account deletion under /profile, the report button and dialog partials, the
maturity gate, and the moderation stylesheet and ReportDialog client.
Every user-generated surface stays reportable by construction: new content tables
are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a
reason, and the registry test fails the suite on anything left unclassified.
Docs: community guidelines, content moderation, intellectual property, privacy,
terms, contact, and the admin-only moderation operations page, plus the
moderation API group and the Devii moderation actions.
Compliance record: applecomp.md is the requirement register, applechanges.md the
gap analysis against this codebase, and appleimpl.md the implementation design
they resolve to.
Tests cover the report flow, admin moderation, consent, account deletion, terms
acceptance, workspaces, and the registry invariant across the unit, api, and e2e
tiers.
2026-08-09 00:18:20 +02:00
|
|
|
pb.locator(".messages-input-area button[type='submit']").last.click()
|
2026-05-16 02:49:53 +02:00
|
|
|
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()
|
2026-06-09 18:48:08 +02:00
|
|
|
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]}"
|
|
|
|
|
)
|
2026-05-16 02:49:53 +02:00
|
|
|
|
|
|
|
|
ctx_a.close()
|
|
|
|
|
ctx_b.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mention_notification_in_post(app_server, browser, seeded_db):
|
|
|
|
|
from tests.conftest import login_user
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-05-16 02:49:53 +02:00
|
|
|
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()
|
2026-06-09 18:48:08 +02:00
|
|
|
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]}"
|
|
|
|
|
)
|
2026-05-16 02:49:53 +02:00
|
|
|
|
|
|
|
|
ctx_a.close()
|
|
|
|
|
ctx_b.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mention_notification_in_comment(app_server, browser, seeded_db):
|
|
|
|
|
from tests.conftest import login_user
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-05-16 02:49:53 +02:00
|
|
|
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()
|
2026-06-09 18:48:08 +02:00
|
|
|
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]}"
|
|
|
|
|
)
|
2026-05-16 02:49:53 +02:00
|
|
|
|
|
|
|
|
ctx_a.close()
|
|
|
|
|
ctx_b.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_reply_notification(app_server, browser, seeded_db):
|
|
|
|
|
from tests.conftest import login_user
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-05-16 02:49:53 +02:00
|
|
|
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()
|
2026-06-09 18:48:08 +02:00
|
|
|
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]}"
|
|
|
|
|
)
|
2026-05-16 02:49:53 +02:00
|
|
|
|
|
|
|
|
ctx_a.close()
|
|
|
|
|
ctx_b.close()
|
2026-05-25 16:16:53 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mention_notification_names_actor(app_server, browser, seeded_db):
|
|
|
|
|
from tests.conftest import login_user
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-05-25 16:16:53 +02:00
|
|
|
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()
|
2026-06-09 18:48:08 +02:00
|
|
|
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}"
|
|
|
|
|
)
|
2026-05-25 16:16:53 +02:00
|
|
|
|
|
|
|
|
ctx_a.close()
|
|
|
|
|
ctx_b.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_comment_notification_click_opens_comment(app_server, browser, seeded_db):
|
|
|
|
|
from tests.conftest import login_user
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-05-25 16:16:53 +02:00
|
|
|
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")
|
2026-06-09 18:48:08 +02:00
|
|
|
card = (
|
|
|
|
|
pa.locator(".notification-card").filter(has_text="commented on your post").first
|
|
|
|
|
)
|
2026-05-25 16:16:53 +02:00
|
|
|
card.wait_for(state="visible", timeout=10000)
|
2026-05-27 22:03:12 +02:00
|
|
|
href = card.locator("a.card-link").get_attribute("href")
|
|
|
|
|
assert href.startswith("/notifications/open/"), f"unexpected href: {href}"
|
2026-05-25 16:16:53 +02:00
|
|
|
|
2026-05-27 22:03:12 +02:00
|
|
|
card.locator("a.card-link").click()
|
2026-05-25 16:16:53 +02:00
|
|
|
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)
|
2026-05-27 22:03:12 +02:00
|
|
|
target = pa.locator(f'.notification-card:has(a.card-link[href="{href}"])')
|
2026-05-25 16:16:53 +02:00
|
|
|
target.wait_for(state="visible", timeout=10000)
|
2026-06-09 18:48:08 +02:00
|
|
|
assert "unread" not in (target.get_attribute("class") or ""), (
|
|
|
|
|
"opening a notification should mark it read"
|
|
|
|
|
)
|
2026-05-25 16:16:53 +02:00
|
|
|
|
|
|
|
|
ctx_a.close()
|
|
|
|
|
ctx_b.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_follow_notification_click_opens_profile(app_server, browser, seeded_db):
|
|
|
|
|
from tests.conftest import login_user
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-05-25 16:16:53 +02:00
|
|
|
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)
|
2026-06-10 05:22:44 +02:00
|
|
|
follow_btn = pb.locator("form[action='/follow/alice_test'] button")
|
2026-05-25 16:16:53 +02:00
|
|
|
if follow_btn.is_visible():
|
|
|
|
|
follow_btn.click()
|
|
|
|
|
pb.wait_for_timeout(1000)
|
|
|
|
|
|
|
|
|
|
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
|
2026-06-09 18:48:08 +02:00
|
|
|
card = (
|
|
|
|
|
pa.locator(".notification-card").filter(has_text="started following you").first
|
|
|
|
|
)
|
2026-05-25 16:16:53 +02:00
|
|
|
card.wait_for(state="visible", timeout=10000)
|
2026-05-27 22:03:12 +02:00
|
|
|
card.locator("a.card-link").click()
|
2026-05-25 16:16:53 +02:00
|
|
|
pa.wait_for_url("**/profile/bob_test", timeout=10000, wait_until="domcontentloaded")
|
2026-06-09 18:48:08 +02:00
|
|
|
assert pa.url.endswith("/profile/bob_test"), (
|
|
|
|
|
f"follow notification should open the follower profile: {pa.url}"
|
|
|
|
|
)
|
2026-05-25 16:16:53 +02:00
|
|
|
|
|
|
|
|
ctx_a.close()
|
|
|
|
|
ctx_b.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_message_notification_click_opens_conversation(app_server, browser, seeded_db):
|
|
|
|
|
from tests.conftest import login_user
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-05-25 16:16:53 +02:00
|
|
|
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)
|
2026-07-23 00:02:43 +02:00
|
|
|
msg_input = pb.locator("textarea[name='content']").first
|
2026-05-25 16:16:53 +02:00
|
|
|
msg_input.wait_for(state="visible", timeout=10000)
|
|
|
|
|
msg_input.fill("Click-through message from bob")
|
Add the trust and safety subsystem and the App Store compliance work
Implements the moderation and consent obligations a social platform carries,
so the web version and any client that speaks to it enforce the same rules.
Moderation core (services/moderation/, database/moderation.py): a reportable
target registry, the content filter and its choke points, the report queue with
atomic resolution, enforcement actions, consent tracking, maturity gating, and
account deletion with a grace window.
Surfaces: POST /reports plus the member report list, /admin/moderation and the
per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and
account deletion under /profile, the report button and dialog partials, the
maturity gate, and the moderation stylesheet and ReportDialog client.
Every user-generated surface stays reportable by construction: new content tables
are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a
reason, and the registry test fails the suite on anything left unclassified.
Docs: community guidelines, content moderation, intellectual property, privacy,
terms, contact, and the admin-only moderation operations page, plus the
moderation API group and the Devii moderation actions.
Compliance record: applecomp.md is the requirement register, applechanges.md the
gap analysis against this codebase, and appleimpl.md the implementation design
they resolve to.
Tests cover the report flow, admin moderation, consent, account deletion, terms
acceptance, workspaces, and the registry invariant across the unit, api, and e2e
tiers.
2026-08-09 00:18:20 +02:00
|
|
|
pb.locator(".messages-input-area button[type='submit']").last.click()
|
2026-05-25 16:16:53 +02:00
|
|
|
pb.wait_for_timeout(1500)
|
|
|
|
|
|
|
|
|
|
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
|
2026-05-27 22:03:12 +02:00
|
|
|
card = pa.locator(".notification-card").filter(has_text="sent you a message").first
|
2026-05-25 16:16:53 +02:00
|
|
|
card.wait_for(state="visible", timeout=10000)
|
2026-05-27 22:03:12 +02:00
|
|
|
card.locator("a.card-link").click()
|
2026-05-25 16:16:53 +02:00
|
|
|
pa.wait_for_url("**/messages**", timeout=10000, wait_until="domcontentloaded")
|
2026-06-09 18:48:08 +02:00
|
|
|
assert "with_uid=" in pa.url, (
|
|
|
|
|
f"message notification should open the conversation: {pa.url}"
|
|
|
|
|
)
|
2026-05-25 16:16:53 +02:00
|
|
|
|
|
|
|
|
ctx_a.close()
|
|
|
|
|
ctx_b.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_vote_notification_click_opens_target(app_server, browser, seeded_db):
|
|
|
|
|
from tests.conftest import login_user
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-05-25 16:16:53 +02:00
|
|
|
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")
|
2026-05-27 22:03:12 +02:00
|
|
|
card = pb.locator(".notification-card").filter(has_text="++'d").first
|
2026-05-25 16:16:53 +02:00
|
|
|
card.wait_for(state="visible", timeout=10000)
|
2026-05-27 22:03:12 +02:00
|
|
|
card.locator("a.card-link").click()
|
2026-05-25 16:16:53 +02:00
|
|
|
pb.wait_for_url(f"**{post_path}", timeout=10000, wait_until="domcontentloaded")
|
2026-06-09 18:48:08 +02:00
|
|
|
assert post_path in pb.url, (
|
|
|
|
|
f"vote notification should open the voted post: {pb.url}"
|
|
|
|
|
)
|
2026-05-25 16:16:53 +02:00
|
|
|
|
|
|
|
|
ctx_a.close()
|
|
|
|
|
ctx_b.close()
|
2026-06-12 06:30:08 +02:00
|
|
|
|
|
|
|
|
|
2026-06-13 16:32:33 +02:00
|
|
|
def test_notifications_page_is_noindex(alice):
|
2026-06-12 06:30:08 +02:00
|
|
|
page, user = alice
|
2026-06-13 16:32:33 +02:00
|
|
|
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
|