Files
devplacepy/tests/api/profile/index.py
T
retoor 8e9d3fad98 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

698 lines
23 KiB
Python

# retoor <retoor@molodetz.nl>
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(
{
"deleted_at": None,
"deleted_by": None,
"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]}",
"terms_version": "1",
"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(
{
"deleted_at": None,
"deleted_by": None,
"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(
{
"deleted_at": None,
"deleted_by": None,
"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(
{
"deleted_at": None,
"deleted_by": None,
"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(
{
"deleted_at": None,
"deleted_by": None,
"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(
{
"deleted_at": None,
"deleted_by": None,
"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_missing_profile_returns_404(app_server):
r = requests.get(f"{BASE_URL}/profile/no-such-user-xyz", allow_redirects=False)
assert r.status_code == 404
def _seed_comment_on_post(commenter_uid):
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
post_slug, post_uid = _seed_post_seo()
comment_uid = str(uuid4())
get_table("comments").insert(
{
"deleted_at": None,
"deleted_by": None,
"uid": comment_uid,
"target_type": "post",
"target_uid": post_uid,
"post_uid": post_uid,
"user_uid": commenter_uid,
"content": "Activity tab link comment body",
"parent_uid": None,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return post_slug, post_uid, comment_uid
def test_activity_comment_exposes_parent_post_url(app_server):
from devplacepy.database import get_table, refresh_snapshot
commenter = _seed_owner()
username = get_table("users").find_one(uid=commenter)["username"]
post_slug, _post_uid, comment_uid = _seed_comment_on_post(commenter)
refresh_snapshot()
r = requests.get(
f"{BASE_URL}/profile/{username}?tab=activity",
headers={"Accept": "application/json"},
)
assert r.status_code == 200
activities = r.json()["activities"]
comment_acts = [a for a in activities if a.get("type") == "comment"]
assert comment_acts, "expected a comment activity"
act = comment_acts[0]
assert act["url"] == f"/posts/{post_slug}#comment-{comment_uid}"
assert act["target_type"] == "post"
def test_activity_comment_url_matches_notification_target(app_server):
from devplacepy.database import get_table, refresh_snapshot, resolve_object_url
commenter = _seed_owner()
username = get_table("users").find_one(uid=commenter)["username"]
_post_slug, post_uid, comment_uid = _seed_comment_on_post(commenter)
refresh_snapshot()
expected = f"{resolve_object_url('post', post_uid)}#comment-{comment_uid}"
r = requests.get(
f"{BASE_URL}/profile/{username}?tab=activity",
headers={"Accept": "application/json"},
)
act = [a for a in r.json()["activities"] if a.get("type") == "comment"][0]
assert act["url"] == expected
def test_activity_post_exposes_url(app_server):
from devplacepy.database import get_table, refresh_snapshot
owner = _seed_owner()
username = get_table("users").find_one(uid=owner)["username"]
post_slug, post_uid = _seed_post_seo()
get_table("posts").update({"uid": post_uid, "user_uid": owner}, ["uid"])
refresh_snapshot()
r = requests.get(
f"{BASE_URL}/profile/{username}?tab=activity",
headers={"Accept": "application/json"},
)
assert r.status_code == 200
post_acts = [a for a in r.json()["activities"] if a.get("type") == "post"]
assert post_acts, "expected a post activity"
assert post_acts[0]["url"] == f"/posts/{post_slug}"
def test_activity_comment_card_renders_overlay_link(app_server):
from devplacepy.database import get_table, refresh_snapshot
commenter = _seed_owner()
username = get_table("users").find_one(uid=commenter)["username"]
post_slug, _post_uid, comment_uid = _seed_comment_on_post(commenter)
refresh_snapshot()
r = requests.get(f"{BASE_URL}/profile/{username}?tab=activity")
assert r.status_code == 200
assert "activity-card card-link-host" in r.text
assert f'class="card-link" href="/posts/{post_slug}#comment-{comment_uid}"' in r.text
def test_profile_json_exposes_online_presence(app_server):
import time
name = f"pres{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",
"birth_date": "1990-01-01",
"accept_terms": "1",
},
allow_redirects=True,
)
r = session.get(
f"{BASE_URL}/profile/{name}", headers={"Accept": "application/json"}
)
assert r.status_code == 200
body = r.json()
assert body["profile_online"] is True
assert body["profile_user"]["last_seen"]
def test_profile_json_offline_when_last_seen_stale(app_server):
from datetime import datetime, timezone, timedelta
from devplacepy.config import PRESENCE_TIMEOUT_SECONDS
from devplacepy.database import get_table, refresh_snapshot
owner = _seed_owner()
username = get_table("users").find_one(uid=owner)["username"]
stale = (
datetime.now(timezone.utc)
- timedelta(seconds=PRESENCE_TIMEOUT_SECONDS + 120)
).isoformat()
get_table("users").update({"uid": owner, "last_seen": stale}, ["uid"])
refresh_snapshot()
r = requests.get(
f"{BASE_URL}/profile/{username}", headers={"Accept": "application/json"}
)
assert r.status_code == 200
body = r.json()
assert body["profile_online"] is False
assert body["profile_user"]["last_seen"] == stale
def test_profile_hero_avatar_has_presence_dot(app_server):
import time
from devplacepy.database import get_table, refresh_snapshot
name = f"phero{int(time.time() * 1000)}"
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
"birth_date": "1990-01-01",
"accept_terms": "1",
},
allow_redirects=True,
)
refresh_snapshot()
uid = get_table("users").find_one(username=name)["uid"]
html = s.get(f"{BASE_URL}/profile/{name}").text
assert "profile-avatar-wrap" in html
assert "presence-dot" in html
assert f'data-presence-uid="{uid}"' in html
def test_followers_list_avatar_has_presence_dot(app_server):
import time
from devplacepy.database import get_table, refresh_snapshot
stamp = int(time.time() * 1000)
target = f"folt{stamp}"
follower = f"folf{stamp}"
st = requests.Session()
sf = requests.Session()
for sess, name in ((st, target), (sf, follower)):
sess.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
"birth_date": "1990-01-01",
"accept_terms": "1",
},
allow_redirects=True,
)
sf.post(f"{BASE_URL}/follow/{target}", allow_redirects=True)
refresh_snapshot()
follower_uid = get_table("users").find_one(username=follower)["uid"]
html = st.get(f"{BASE_URL}/profile/{target}?tab=followers").text
assert "follow-user" in html
assert f'data-presence-uid="{follower_uid}"' in html
assert "data-presence-last-seen" in html
def test_viewing_profile_marks_notification_read(app_server):
import time
from datetime import datetime, timezone
from devplacepy.database import get_table, refresh_snapshot
from devplacepy.utils import generate_uid
name = f"nmark{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",
"birth_date": "1990-01-01",
"accept_terms": "1",
},
allow_redirects=True,
)
refresh_snapshot()
user = get_table("users").find_one(username=name)
notif_uid = generate_uid()
get_table("notifications").insert(
{
"uid": notif_uid,
"user_uid": user["uid"],
"type": "follow",
"message": "someone followed you",
"related_uid": generate_uid(),
"target_url": f"/profile/{name}",
"read": False,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
refresh_snapshot()
r = session.get(f"{BASE_URL}/profile/{name}", allow_redirects=True)
assert r.status_code == 200
refresh_snapshot()
assert bool(get_table("notifications").find_one(uid=notif_uid)["read"]) is True
def test_own_profile_json_exposes_xp_fields(app_server, seeded_db):
"""GET /profile (own) with Accept: application/json includes xp_next_level and xp_progress_pct."""
import requests
session = requests.Session()
session.post(
f"{BASE_URL}/auth/login",
data={
"email": seeded_db["alice"]["email"],
"password": seeded_db["alice"]["password"],
},
allow_redirects=True,
)
r = session.get(f"{BASE_URL}/profile", headers={"Accept": "application/json"})
assert r.status_code == 200
data = r.json()
pu = data["profile_user"]
assert "xp_next_level" in pu, "xp_next_level missing from own profile JSON"
assert "xp_progress_pct" in pu, "xp_progress_pct missing from own profile JSON"
assert isinstance(pu["xp_next_level"], int)
assert isinstance(pu["xp_progress_pct"], int)
def test_other_profile_json_exposes_xp_fields(app_server):
"""GET /profile/{username} with Accept: application/json includes xp_next_level and xp_progress_pct."""
from devplacepy.database import get_table, refresh_snapshot
owner = _seed_owner()
username = get_table("users").find_one(uid=owner)["username"]
refresh_snapshot()
r = requests.get(
f"{BASE_URL}/profile/{username}", headers={"Accept": "application/json"}
)
assert r.status_code == 200
data = r.json()
pu = data["profile_user"]
assert "xp_next_level" in pu, "xp_next_level missing from other profile JSON"
assert "xp_progress_pct" in pu, "xp_progress_pct missing from other profile JSON"
assert isinstance(pu["xp_next_level"], int)
assert isinstance(pu["xp_progress_pct"], int)
def test_profile_json_xp_fields_zero_xp(app_server):
"""User with 0 XP returns xp_next_level=100 (level 1) and xp_progress_pct=0."""
from devplacepy.database import get_table, refresh_snapshot
from devplacepy.utils.rewards import LEVEL_XP
owner = _seed_owner()
username = get_table("users").find_one(uid=owner)["username"]
refresh_snapshot()
r = requests.get(
f"{BASE_URL}/profile/{username}", headers={"Accept": "application/json"}
)
assert r.status_code == 200
pu = r.json()["profile_user"]
assert pu["xp"] == 0 or pu["xp"] is None, f"expected 0 xp, got {pu['xp']}"
assert pu["xp_next_level"] == LEVEL_XP, (
f"expected xp_next_level={LEVEL_XP} for level 1, got {pu['xp_next_level']}"
)
assert pu["xp_progress_pct"] == 0, (
f"expected xp_progress_pct=0 for 0 XP, got {pu['xp_progress_pct']}"
)
def test_profile_json_xp_fields_boundary_xp(app_server):
"""User with exactly 100 XP (level 2) returns xp_next_level=200 and xp_progress_pct=0."""
from devplacepy.database import get_table, refresh_snapshot
from devplacepy.utils.rewards import award_xp
owner = _seed_owner()
username = get_table("users").find_one(uid=owner)["username"]
award_xp(owner, 100)
refresh_snapshot()
r = requests.get(
f"{BASE_URL}/profile/{username}", headers={"Accept": "application/json"}
)
assert r.status_code == 200
pu = r.json()["profile_user"]
assert pu["xp"] == 100, f"expected 100 xp, got {pu['xp']}"
assert pu["level"] == 2, f"expected level 2, got {pu['level']}"
assert pu["xp_next_level"] == 200, (
f"expected xp_next_level=200 for level 2, got {pu['xp_next_level']}"
)
assert pu["xp_progress_pct"] == 0, (
f"expected xp_progress_pct=0 at boundary, got {pu['xp_progress_pct']}"
)
def _signup_badge_user():
import time
name = f"bdgi{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",
"birth_date": "1990-01-01",
"accept_terms": "1",
},
allow_redirects=True,
)
return session, name
def test_profile_badges_json_description_matches_catalog(app_server):
from devplacepy.database import get_table, refresh_snapshot
from devplacepy.utils import BADGE_CATALOG, award_badge
session, name = _signup_badge_user()
refresh_snapshot()
user = get_table("users").find_one(username=name)
assert user, f"user {name} not found after signup"
award_badge(user["uid"], "First Post")
award_badge(user["uid"], "Member")
refresh_snapshot()
r = session.get(
f"{BASE_URL}/profile/{name}", headers={"Accept": "application/json"}
)
assert r.status_code == 200
body = r.json()
assert "badges" in body, "badges key missing from profile JSON"
assert isinstance(body["badges"], list), "badges is not a list"
assert body["badges"], "expected at least one badge"
for badge in body["badges"]:
assert "description" in badge, f"badge missing description key: {badge}"
assert badge["description"] is not None, f"badge description is null: {badge}"
assert isinstance(badge["description"], str), (
f"badge description is not a string: {badge}"
)
assert badge["description"] == BADGE_CATALOG[badge["name"]]["description"], (
f"badge description mismatch: {badge['name']}"
)
def test_profile_badge_description_exact_string(app_server):
from devplacepy.database import get_table, refresh_snapshot
from devplacepy.utils import award_badge
session, name = _signup_badge_user()
refresh_snapshot()
user = get_table("users").find_one(username=name)
assert user, f"user {name} not found after signup"
award_badge(user["uid"], "Cheerleader")
refresh_snapshot()
r = session.get(
f"{BASE_URL}/profile/{name}", headers={"Accept": "application/json"}
)
assert r.status_code == 200
badges = r.json()["badges"]
cheerleader = [b for b in badges if b["name"] == "Cheerleader"]
assert cheerleader, f"Cheerleader badge missing from profile JSON: {badges}"
assert cheerleader[0]["description"] == "Reacted 50 times", (
f"expected 'Reacted 50 times', got {cheerleader[0]['description']!r}"
)
PRIVACY_FIELDS = (
"age_band",
"terms_version",
"terms_accepted_at",
"suspended_until",
"suspension_reason",
)
def _privacy_member(prefix="priv"):
import time
name = f"{prefix}{int(time.time() * 1000000)}"
session = requests.Session()
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
"birth_date": "1990-01-01",
"accept_terms": "1",
},
allow_redirects=True,
)
return session, name
def _admin_session():
session = requests.Session()
session.post(
f"{BASE_URL}/auth/login",
data={"email": "alice@test.devplace", "password": "secret123"},
allow_redirects=True,
)
return session
def test_the_owner_reads_their_own_privacy_state(app_server):
session, name = _privacy_member()
body = session.get(
f"{BASE_URL}/profile/{name}?tab=privacy",
headers={"Accept": "application/json"},
).json()
assert body["age_band"] == "adult"
assert body["terms_version"]
assert body["terms_accepted_at"]
assert body["consents"]
def test_privacy_state_is_withheld_from_a_stranger(app_server):
_, name = _privacy_member()
stranger, _ = _privacy_member("prvs")
body = stranger.get(
f"{BASE_URL}/profile/{name}?tab=privacy",
headers={"Accept": "application/json"},
).json()
for field in PRIVACY_FIELDS:
assert body[field] in ("", None), f"{field} leaked to a stranger: {body[field]!r}"
assert body["mature_opt_in"] is False
assert body["consents"] == []
def test_privacy_state_is_withheld_from_a_guest(app_server):
_, name = _privacy_member()
body = requests.get(
f"{BASE_URL}/profile/{name}?tab=privacy",
headers={"Accept": "application/json"},
).json()
for field in PRIVACY_FIELDS:
assert body[field] in ("", None), f"{field} leaked to a guest: {body[field]!r}"
assert body["consents"] == []
def test_a_strangers_profile_html_never_renders_the_privacy_panel(app_server):
_, name = _privacy_member()
stranger, _ = _privacy_member("prvh")
html = stranger.get(f"{BASE_URL}/profile/{name}?tab=privacy").text
assert "privacy-panel" not in html
assert f"/profile/{name}/consent" not in html
assert f"/profile/{name}/delete" not in html
def test_an_admin_reads_the_state_but_gets_no_controls(app_server, seeded_db):
_, name = _privacy_member()
admin = _admin_session()
body = admin.get(
f"{BASE_URL}/profile/{name}?tab=privacy",
headers={"Accept": "application/json"},
).json()
assert body["age_band"] == "adult"
assert body["consents"]
html = admin.get(f"{BASE_URL}/profile/{name}?tab=privacy").text
assert "privacy-panel" in html
assert "Only the account holder can change this." in html
assert "Only the account holder can change this preference." in html
assert f'action="/profile/{name}/consent"' not in html
assert f'action="/profile/{name}/mature-content"' not in html
assert f"/profile/{name}/delete" not in html
def test_the_owner_gets_the_privacy_controls(app_server):
session, name = _privacy_member()
html = session.get(f"{BASE_URL}/profile/{name}?tab=privacy").text
assert f'action="/profile/{name}/consent"' in html
assert f'action="/profile/{name}/mature-content"' in html
assert f"/profile/{name}/delete" in html
assert "Only the account holder can change this." not in html