2026-06-13 16:32:33 +02:00
|
|
|
# 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(
|
|
|
|
|
{
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
feat: add per-user avatar seed regeneration with irreversible random avatar replacement
Implement a new `avatar_seed` column on the users table that overrides the username-based seed for Multiavatar generation. Introduce a null-safe `avatar_seed(user)` choke point in `avatar.py` that resolves `user.get("avatar_seed") or user.get("username")`, registered as a Jinja global so every render site (`_avatar_link.html`, `avatar_url(...)` calls, SEO `og_image`, issues ad-hoc dicts, devRant payload/PNG) propagates a regenerated seed. Add `POST /profile/{username}/regenerate-avatar` endpoint (owner-or-admin only) that writes a fresh `generate_uid()` to `avatar_seed`, invalidates the target's user cache, and audits `profile.avatar.regenerate`. The previous seed is overwritten and never stored, making regeneration irreversible. Document the feature in `AGENTS.md` and `README.md`, add the API endpoint to `docs_api.py`, and include the `regenerate_avatar` Devii tool in `CONFIRM_REQUIRED`.
2026-06-28 00:31:34 +02:00
|
|
|
|
|
|
|
|
|
2026-07-04 22:24:59 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-07-05 00:08:20 +02:00
|
|
|
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",
|
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
|
|
|
"birth_date": "1990-01-01",
|
|
|
|
|
"accept_terms": "1",
|
2026-07-05 00:08:20 +02:00
|
|
|
},
|
|
|
|
|
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",
|
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
|
|
|
"birth_date": "1990-01-01",
|
|
|
|
|
"accept_terms": "1",
|
2026-07-05 00:08:20 +02:00
|
|
|
},
|
|
|
|
|
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",
|
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
|
|
|
"birth_date": "1990-01-01",
|
|
|
|
|
"accept_terms": "1",
|
2026-07-05 00:08:20 +02:00
|
|
|
},
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
feat: add per-user avatar seed regeneration with irreversible random avatar replacement
Implement a new `avatar_seed` column on the users table that overrides the username-based seed for Multiavatar generation. Introduce a null-safe `avatar_seed(user)` choke point in `avatar.py` that resolves `user.get("avatar_seed") or user.get("username")`, registered as a Jinja global so every render site (`_avatar_link.html`, `avatar_url(...)` calls, SEO `og_image`, issues ad-hoc dicts, devRant payload/PNG) propagates a regenerated seed. Add `POST /profile/{username}/regenerate-avatar` endpoint (owner-or-admin only) that writes a fresh `generate_uid()` to `avatar_seed`, invalidates the target's user cache, and audits `profile.avatar.regenerate`. The previous seed is overwritten and never stored, making regeneration irreversible. Document the feature in `AGENTS.md` and `README.md`, add the API endpoint to `docs_api.py`, and include the `regenerate_avatar` Devii tool in `CONFIRM_REQUIRED`.
2026-06-28 00:31:34 +02:00
|
|
|
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",
|
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
|
|
|
"birth_date": "1990-01-01",
|
|
|
|
|
"accept_terms": "1",
|
feat: add per-user avatar seed regeneration with irreversible random avatar replacement
Implement a new `avatar_seed` column on the users table that overrides the username-based seed for Multiavatar generation. Introduce a null-safe `avatar_seed(user)` choke point in `avatar.py` that resolves `user.get("avatar_seed") or user.get("username")`, registered as a Jinja global so every render site (`_avatar_link.html`, `avatar_url(...)` calls, SEO `og_image`, issues ad-hoc dicts, devRant payload/PNG) propagates a regenerated seed. Add `POST /profile/{username}/regenerate-avatar` endpoint (owner-or-admin only) that writes a fresh `generate_uid()` to `avatar_seed`, invalidates the target's user cache, and audits `profile.avatar.regenerate`. The previous seed is overwritten and never stored, making regeneration irreversible. Document the feature in `AGENTS.md` and `README.md`, add the API endpoint to `docs_api.py`, and include the `regenerate_avatar` Devii tool in `CONFIRM_REQUIRED`.
2026-06-28 00:31:34 +02:00
|
|
|
},
|
|
|
|
|
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
|
2026-07-27 01:25:53 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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={
|
Fix circular import, primary-admin NULL trap, and add gateway quota reset
Restores a working import graph and closes two data-correctness bugs, plus
adds a reset for the AI gateway's rolling 24h spend.
Circular import: database/__init__ -> engagement -> content -> utils ->
database made the package unimportable. get_project_devlog moves out of
database/engagement.py into content.py, where enrich_items already lives.
Primary administrator: _can_hold_primary_admin read is_active with
bool(row.get("is_active")), so an admin row whose is_active column is SQL
NULL (any row predating the column) was treated as deactivated and skipped.
Every other site defaults an unknown is_active to active; this one now does
too.
Profile JSON: xp_next_level and xp_progress_pct were computed but only put on
the top-level context, never on profile_user, so they serialised as null even
though UserOut declares them and the API docs document them as embedded there.
Gateway quota reset: a cap previously lifted only with the passage of time.
quota.reset upserts a watermark row into gateway_quota_resets, scoped by the
same three nullable dimensions as a quota rule, and spent_24h sums from
max(24h cutoff, watermark). No ledger row is deleted, so the cost analytics on
/admin/ai-usage stay intact. Reaches every surface: POST
/admin/gateway/quota-resets, a per-rule Reset spend button, the Devii tool
gateway_quota_reset (confirm-gated), devplace gateway quota reset, and the API
docs. Admin's Reset all quotas now stamps a global gateway watermark too,
which is what a caller stuck on "AI gateway daily quota exceeded" needed.
Startup: _backfill_gamification swept every xp=0 user on every boot in every
worker and could never converge, since a user with no content earns no XP.
It now intersects pending users with _milestone_candidates(). db.tables is a
live reflection, so it is hoisted out of the loops that probed it per row.
Docker: the dependency layer now depends on pyproject.toml only, so a source
edit no longer reinstalls every dependency and re-downloads Chromium.
Adds start_interval so the healthcheck probes during the start period, and a
docker-reload target, since docker-up does not restart an unchanged container.
Adds events.md, the audit event catalogue that README, CLAUDE.md, the quiz
docs and the tooling all referenced but which never existed: 288 keys across
28 categories, including the families built from a variable at the call site.
Test fixes: both devlog helpers dated post 0 as the newest while the tests
assumed post 2 was; a profile login posted username= to a form that takes
email=; a devlog assertion matched six buttons under strict mode; and the
primary-admin tests seeded founders newer than the back-dated fixture admin,
so they only passed without the api tier.
Full suite: 2989 passed, 1 skipped.
2026-07-27 11:17:48 +02:00
|
|
|
"email": seeded_db["alice"]["email"],
|
2026-07-27 01:25:53 +02:00
|
|
|
"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']}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-08-04 19:06:54 +02:00
|
|
|
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",
|
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
|
|
|
"birth_date": "1990-01-01",
|
|
|
|
|
"accept_terms": "1",
|
2026-08-04 19:06:54 +02:00
|
|
|
},
|
|
|
|
|
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}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-04 19:06:54 +02:00
|
|
|
|