2026-06-13 16:32:33 +02:00
|
|
|
# retoor <retoor@molodetz.nl>
|
|
|
|
|
|
|
|
|
|
import base64
|
|
|
|
|
import time
|
|
|
|
|
import requests
|
|
|
|
|
from tests.conftest import BASE_URL
|
|
|
|
|
from devplacepy.database import get_table
|
|
|
|
|
_counter_api_auth = [0]
|
|
|
|
|
def _signup_api_auth(password="secret123"):
|
|
|
|
|
_counter_api_auth[0] += 1
|
|
|
|
|
name = f"apiauth{int(time.time() * 1000)}{_counter_api_auth[0]}"
|
|
|
|
|
email = f"{name}@t.dev"
|
|
|
|
|
session = requests.Session()
|
|
|
|
|
session.post(
|
|
|
|
|
f"{BASE_URL}/auth/signup",
|
|
|
|
|
data={
|
|
|
|
|
"username": name,
|
|
|
|
|
"email": email,
|
|
|
|
|
"password": password,
|
|
|
|
|
"confirm_password": password,
|
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-06-13 16:32:33 +02:00
|
|
|
},
|
|
|
|
|
allow_redirects=True,
|
|
|
|
|
)
|
|
|
|
|
return session, name, email, password
|
|
|
|
|
def _user_api_auth(name):
|
|
|
|
|
return get_table("users").find_one(username=name)
|
|
|
|
|
def _key_api_auth(name):
|
|
|
|
|
return _user_api_auth(name)["api_key"]
|
|
|
|
|
from playwright.sync_api import expect
|
|
|
|
|
from devplacepy.database import (
|
|
|
|
|
CUSTOMIZATION_GLOBAL_SCOPE,
|
|
|
|
|
get_customization_prefs,
|
|
|
|
|
get_table,
|
|
|
|
|
list_custom_overrides,
|
|
|
|
|
set_custom_override,
|
|
|
|
|
set_customization_pref,
|
|
|
|
|
)
|
|
|
|
|
from devplacepy.utils import clear_user_cache
|
|
|
|
|
def _user_customization_toggle(username):
|
|
|
|
|
return get_table("users").find_one(username=username)
|
|
|
|
|
def _seed_global_css(uid, sentinel):
|
|
|
|
|
set_custom_override(
|
|
|
|
|
"user", uid, CUSTOMIZATION_GLOBAL_SCOPE, "css", f".{sentinel} {{ color: red; }}"
|
|
|
|
|
)
|
|
|
|
|
def _profile(page, username):
|
|
|
|
|
page.goto(f"{BASE_URL}/profile/{username}", wait_until="domcontentloaded")
|
|
|
|
|
def _click_toggle(page, category):
|
|
|
|
|
page.locator(f"[data-customization-toggle='{category}']").click()
|
|
|
|
|
page.locator(".dialog-overlay.visible .dialog-confirm").click()
|
|
|
|
|
import re
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
from tests.conftest import BASE_URL, assert_share_copies
|
|
|
|
|
from devplacepy.utils import make_combined_slug
|
|
|
|
|
DPBOT_SIZE = 112147
|
|
|
|
|
SOURCE_LENGTH_LIMIT = 400000
|
|
|
|
|
def _make_source(length):
|
|
|
|
|
unit = "def handler():\n return 0\n"
|
|
|
|
|
return (unit * (length // len(unit) + 1))[:length]
|
|
|
|
|
def _register_session(prefix):
|
|
|
|
|
name = f"{prefix}{int(time.time() * 1000)}"
|
|
|
|
|
session = requests.Session()
|
|
|
|
|
session.post(
|
|
|
|
|
f"{BASE_URL}/auth/signup",
|
|
|
|
|
data={
|
|
|
|
|
"username": name,
|
|
|
|
|
"email": f"{name}@t.dev",
|
|
|
|
|
"password": "secret123",
|
|
|
|
|
"confirm_password": "secret123",
|
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-06-13 16:32:33 +02:00
|
|
|
},
|
|
|
|
|
allow_redirects=True,
|
|
|
|
|
)
|
|
|
|
|
return session, name
|
|
|
|
|
def _seed_gists(count):
|
|
|
|
|
owner = str(uuid4())
|
|
|
|
|
get_table("users").insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": owner,
|
|
|
|
|
"username": f"pag_{owner[: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"{owner[:8]}@test.devplace",
|
|
|
|
|
"password_hash": "x",
|
|
|
|
|
"role": "Member",
|
|
|
|
|
"is_active": True,
|
|
|
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
|
|
|
gists = get_table("gists")
|
|
|
|
|
for i in range(count):
|
|
|
|
|
uid = str(uuid4())
|
|
|
|
|
title = f"Pag Gist {i}"
|
|
|
|
|
gists.insert(
|
|
|
|
|
{
|
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(title, uid),
|
|
|
|
|
"title": title,
|
|
|
|
|
"language": "python",
|
|
|
|
|
"description": "paginated",
|
|
|
|
|
"stars": 0,
|
|
|
|
|
"created_at": (base - timedelta(seconds=i)).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return owner
|
|
|
|
|
def _set_cm_value(page, value):
|
|
|
|
|
page.evaluate(f"""() => {{
|
|
|
|
|
const cm = document.querySelector(".CodeMirror")?.CodeMirror;
|
|
|
|
|
if (cm) {{
|
|
|
|
|
cm.setValue({repr(value)});
|
|
|
|
|
cm.save();
|
|
|
|
|
}}
|
|
|
|
|
}}""")
|
|
|
|
|
def _create_gist(
|
|
|
|
|
page,
|
|
|
|
|
title="Test Gist",
|
|
|
|
|
description="Test description",
|
|
|
|
|
language="python",
|
|
|
|
|
source_code="print('hello')",
|
|
|
|
|
):
|
|
|
|
|
page.goto(f"{BASE_URL}/gists", wait_until="domcontentloaded")
|
|
|
|
|
page.locator("#create-gist-btn").wait_for(state="visible", timeout=10000)
|
|
|
|
|
page.locator("#create-gist-btn").click()
|
|
|
|
|
page.wait_for_timeout(800)
|
|
|
|
|
page.fill("#gist-title", title)
|
|
|
|
|
page.fill("#gist-description", description)
|
|
|
|
|
page.select_option("#gist-language", language)
|
|
|
|
|
page.wait_for_timeout(300)
|
|
|
|
|
_set_cm_value(page, source_code)
|
|
|
|
|
page.wait_for_timeout(300)
|
|
|
|
|
page.locator("button.btn-primary:has-text('Create Gist')").click()
|
|
|
|
|
page.wait_for_timeout(2000)
|
|
|
|
|
current = page.url
|
|
|
|
|
if "/gists/" in current and current != f"{BASE_URL}/gists":
|
|
|
|
|
return
|
|
|
|
|
page.wait_for_url("**/gists/*", timeout=15000, wait_until="domcontentloaded")
|
|
|
|
|
def _seed_searchable_gists(language="python"):
|
|
|
|
|
token = uuid4().hex[:10]
|
|
|
|
|
owner = str(uuid4())
|
|
|
|
|
get_table("users").insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": owner,
|
|
|
|
|
"username": f"gsrch_{owner[:8]}",
|
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"{owner[:8]}@test.devplace",
|
|
|
|
|
"password_hash": "x",
|
|
|
|
|
"role": "Member",
|
|
|
|
|
"is_active": True,
|
|
|
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
|
|
|
gists = get_table("gists")
|
|
|
|
|
match_title = f"Findable gist {token}"
|
|
|
|
|
other_title = f"Unrelated gist {uuid4().hex[:10]}"
|
|
|
|
|
for offset, title in ((0, match_title), (1, other_title)):
|
|
|
|
|
uid = str(uuid4())
|
|
|
|
|
gists.insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": uid,
|
|
|
|
|
"user_uid": owner,
|
|
|
|
|
"slug": make_combined_slug(title, uid),
|
|
|
|
|
"title": title,
|
|
|
|
|
"language": language,
|
|
|
|
|
"description": f"Description for {title}",
|
|
|
|
|
"source_code": "x = 1",
|
|
|
|
|
"stars": 0,
|
|
|
|
|
"created_at": (base - timedelta(seconds=offset)).isoformat(),
|
|
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return token, match_title, other_title
|
|
|
|
|
import io
|
|
|
|
|
import uuid
|
|
|
|
|
from PIL import Image
|
|
|
|
|
JSON_media = {"Accept": "application/json"}
|
|
|
|
|
def _png_bytes_media(color=(200, 30, 30)):
|
|
|
|
|
buf = io.BytesIO()
|
|
|
|
|
Image.new("RGB", (8, 8), color).save(buf, "PNG")
|
|
|
|
|
return buf.getvalue()
|
|
|
|
|
def _signup_media(prefix="media"):
|
|
|
|
|
s = requests.Session()
|
|
|
|
|
name = f"{prefix}_{uuid.uuid4().hex[:10]}"
|
|
|
|
|
s.post(
|
|
|
|
|
f"{BASE_URL}/auth/signup",
|
|
|
|
|
data={
|
|
|
|
|
"username": name,
|
|
|
|
|
"email": f"{name}@test.devplace",
|
|
|
|
|
"password": "secret123",
|
|
|
|
|
"confirm_password": "secret123",
|
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-06-13 16:32:33 +02:00
|
|
|
},
|
|
|
|
|
allow_redirects=True,
|
|
|
|
|
)
|
|
|
|
|
return s, name
|
|
|
|
|
def _login_media(seeded_db, who):
|
|
|
|
|
s = requests.Session()
|
|
|
|
|
creds = seeded_db[who]
|
|
|
|
|
s.post(
|
|
|
|
|
f"{BASE_URL}/auth/login",
|
|
|
|
|
data={"email": creds["email"], "password": creds["password"]},
|
|
|
|
|
allow_redirects=True,
|
|
|
|
|
)
|
|
|
|
|
return s
|
|
|
|
|
def _upload_media(s, name="pic.png", content=None, mime="image/png"):
|
|
|
|
|
files = {"file": (name, content if content is not None else _png_bytes_media(), mime)}
|
|
|
|
|
r = s.post(f"{BASE_URL}/uploads/upload", files=files)
|
|
|
|
|
assert r.status_code == 201, r.text
|
|
|
|
|
return r.json()["uid"]
|
|
|
|
|
def _create_post_media(s, attachment_uids, title="media post"):
|
|
|
|
|
r = s.post(
|
|
|
|
|
f"{BASE_URL}/posts/create",
|
|
|
|
|
data={
|
|
|
|
|
"content": "A post that carries media for the gallery tests.",
|
|
|
|
|
"title": title,
|
|
|
|
|
"topic": "random",
|
|
|
|
|
"attachment_uids": attachment_uids,
|
|
|
|
|
},
|
|
|
|
|
allow_redirects=True,
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == 200, r.text[:300]
|
|
|
|
|
assert "/posts/" in r.url, r.url
|
|
|
|
|
return r.url
|
|
|
|
|
def _create_project_media(s, attachment_uids, title="Media Project"):
|
|
|
|
|
r = s.post(
|
|
|
|
|
f"{BASE_URL}/projects/create",
|
|
|
|
|
data={
|
|
|
|
|
"title": title,
|
|
|
|
|
"description": "Project with media for gallery tests.",
|
|
|
|
|
"project_type": "software",
|
|
|
|
|
"platforms": "linux",
|
|
|
|
|
"status": "In Development",
|
|
|
|
|
"attachment_uids": attachment_uids,
|
|
|
|
|
},
|
|
|
|
|
allow_redirects=True,
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == 200, r.text[:300]
|
|
|
|
|
assert "/projects/" in r.url, r.url
|
|
|
|
|
return r.url
|
|
|
|
|
def _media_json(session, username):
|
|
|
|
|
r = session.get(f"{BASE_URL}/profile/{username}?tab=media", headers=JSON_media)
|
|
|
|
|
assert r.status_code == 200, r.text[:300]
|
|
|
|
|
return r.json()
|
|
|
|
|
def _media_uids(session, username):
|
|
|
|
|
return [m["uid"] for m in _media_json(session, username)["media"]]
|
|
|
|
|
def _seed_media_via_browser(page, title="ui media"):
|
|
|
|
|
up = page.request.post(
|
|
|
|
|
f"{BASE_URL}/uploads/upload",
|
|
|
|
|
multipart={
|
|
|
|
|
"file": {
|
|
|
|
|
"name": "pic.png",
|
|
|
|
|
"mimeType": "image/png",
|
|
|
|
|
"buffer": _png_bytes_media(),
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
assert up.ok, up.text()
|
|
|
|
|
uid = up.json()["uid"]
|
|
|
|
|
post = page.request.post(
|
|
|
|
|
f"{BASE_URL}/posts/create",
|
|
|
|
|
form={
|
|
|
|
|
"content": "media for the ui gallery test",
|
|
|
|
|
"title": title,
|
|
|
|
|
"topic": "random",
|
|
|
|
|
"attachment_uids": uid,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
assert post.ok, post.text()
|
|
|
|
|
return uid
|
|
|
|
|
def _media_uids_via_page(page, username):
|
|
|
|
|
resp = page.request.get(f"{BASE_URL}/profile/{username}?tab=media", headers=JSON_media)
|
|
|
|
|
return [m["uid"] for m in resp.json()["media"]]
|
|
|
|
|
from devplacepy.database import (
|
|
|
|
|
get_notification_default,
|
|
|
|
|
get_notification_prefs,
|
|
|
|
|
get_table,
|
|
|
|
|
notification_enabled,
|
|
|
|
|
reset_notification_prefs,
|
|
|
|
|
set_notification_default,
|
|
|
|
|
set_notification_pref,
|
|
|
|
|
)
|
|
|
|
|
from devplacepy.utils import create_notification, generate_uid
|
|
|
|
|
def _user_notification_prefs(username):
|
|
|
|
|
return get_table("users").find_one(username=username)
|
|
|
|
|
def _pref(prefs, key):
|
|
|
|
|
return next(p for p in prefs if p["key"] == key)
|
|
|
|
|
def _seed_news_seo():
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
from devplacepy.database import get_table
|
|
|
|
|
from devplacepy.utils import generate_uid, make_combined_slug
|
|
|
|
|
|
|
|
|
|
uid = generate_uid()
|
|
|
|
|
title = f"SEO Test News Article {uid.split('-')[-1]}"
|
|
|
|
|
slug = make_combined_slug(title, uid)
|
|
|
|
|
get_table("news").insert(
|
|
|
|
|
{
|
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_admin_sees_other_users_key(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
_, member, _, _ = _signup_api_auth()
|
|
|
|
|
page.goto(f"{BASE_URL}/profile/{member}", wait_until="domcontentloaded")
|
|
|
|
|
assert page.locator("[data-api-key-card]").count() == 1
|
|
|
|
|
assert _key_api_auth(member) in page.content()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_non_owner_non_admin_hidden_and_guarded(bob):
|
|
|
|
|
page, user = bob
|
|
|
|
|
member = _user_customization_toggle(user["username"])
|
|
|
|
|
get_table("users").update({"uid": member["uid"], "role": "Member"}, ["uid"])
|
|
|
|
|
clear_user_cache(member["uid"])
|
|
|
|
|
target = _user_customization_toggle("alice_test")
|
|
|
|
|
|
|
|
|
|
_profile(page, target["username"])
|
|
|
|
|
assert page.locator("[data-customization]").count() == 0
|
|
|
|
|
|
|
|
|
|
response = requests.post(
|
|
|
|
|
f"{BASE_URL}/profile/{target['username']}/customization/global",
|
|
|
|
|
data={"value": "1"},
|
|
|
|
|
headers={"Accept": "application/json", "X-API-KEY": member["api_key"]},
|
|
|
|
|
allow_redirects=False,
|
|
|
|
|
)
|
|
|
|
|
assert response.status_code == 403
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_profile_gists_tab(alice, app_server):
|
|
|
|
|
page, alice_user = alice
|
|
|
|
|
title = f"Profile Tab Test {int(time.time())}"
|
|
|
|
|
_create_gist(page, title=title, source_code="profile_tab = True")
|
|
|
|
|
page.goto(
|
|
|
|
|
f"{BASE_URL}/profile/{alice_user['username']}?tab=gists",
|
|
|
|
|
wait_until="domcontentloaded",
|
|
|
|
|
)
|
|
|
|
|
assert page.is_visible(f"text={title}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_profile_rank_stat(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
|
|
|
|
|
assert page.is_visible("text=Rank")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ui_media_tab_link_visible(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
|
|
|
|
|
assert page.locator(".profile-tab:has-text('Media')").is_visible()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ui_media_tab_shows_thumbnail(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
_seed_media_via_browser(page)
|
|
|
|
|
page.goto(
|
|
|
|
|
f"{BASE_URL}/profile/{user['username']}?tab=media",
|
|
|
|
|
wait_until="domcontentloaded",
|
|
|
|
|
)
|
|
|
|
|
assert page.locator(".profile-tab.active:has-text('Media')").is_visible()
|
|
|
|
|
thumb = page.locator(".media-grid img.gallery-thumb[data-lightbox]").first
|
|
|
|
|
thumb.wait_for(state="visible")
|
|
|
|
|
assert thumb.get_attribute("data-full")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ui_media_lightbox_opens(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
_seed_media_via_browser(page)
|
|
|
|
|
page.goto(
|
|
|
|
|
f"{BASE_URL}/profile/{user['username']}?tab=media",
|
|
|
|
|
wait_until="domcontentloaded",
|
|
|
|
|
)
|
|
|
|
|
thumb = page.locator(".media-grid img.gallery-thumb[data-lightbox]").first
|
|
|
|
|
thumb.wait_for(state="visible")
|
|
|
|
|
thumb.click()
|
|
|
|
|
overlay = page.locator(".lightbox-overlay.visible")
|
|
|
|
|
overlay.wait_for(state="visible")
|
2026-06-19 10:06:09 +02:00
|
|
|
expect(page.locator(".lightbox-overlay.visible .lightbox-image")).to_be_visible()
|
2026-06-13 16:32:33 +02:00
|
|
|
page.locator(".lightbox-overlay.visible .lightbox-close").click()
|
|
|
|
|
overlay.wait_for(state="hidden")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ui_owner_delete_removes_tile(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
uid = _seed_media_via_browser(page, title="to delete")
|
|
|
|
|
page.goto(
|
|
|
|
|
f"{BASE_URL}/profile/{user['username']}?tab=media",
|
|
|
|
|
wait_until="domcontentloaded",
|
|
|
|
|
)
|
|
|
|
|
tile = page.locator(f".media-tile[data-media-tile='{uid}']")
|
|
|
|
|
tile.wait_for(state="attached")
|
|
|
|
|
page.locator(f".media-tile[data-media-tile='{uid}'] .media-delete-btn").click()
|
|
|
|
|
page.locator(".dialog-overlay.visible .dialog-confirm").click()
|
|
|
|
|
tile.wait_for(state="detached")
|
|
|
|
|
assert page.locator(f".media-tile[data-media-tile='{uid}']").count() == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ui_member_sees_no_delete_button_on_other_profile(bob, alice):
|
|
|
|
|
# alice (admin) seeds media on her own profile
|
|
|
|
|
page_a, user_a = alice
|
|
|
|
|
_seed_media_via_browser(page_a)
|
|
|
|
|
# bob (member) views alice's gallery: no delete controls
|
|
|
|
|
page_b, _ = bob
|
|
|
|
|
page_b.goto(
|
|
|
|
|
f"{BASE_URL}/profile/{user_a['username']}?tab=media",
|
|
|
|
|
wait_until="domcontentloaded",
|
|
|
|
|
)
|
|
|
|
|
page_b.locator(".media-grid img.gallery-thumb").first.wait_for(state="visible")
|
|
|
|
|
assert page_b.locator(".media-delete-btn").count() == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ui_mobile_media_grid(mobile_page):
|
|
|
|
|
page, user = mobile_page
|
|
|
|
|
_seed_media_via_browser(page)
|
|
|
|
|
page.goto(
|
|
|
|
|
f"{BASE_URL}/profile/{user['username']}?tab=media",
|
|
|
|
|
wait_until="domcontentloaded",
|
|
|
|
|
)
|
|
|
|
|
grid = page.locator(".media-grid")
|
|
|
|
|
grid.wait_for(state="visible")
|
|
|
|
|
assert page.locator(".media-grid .media-tile").count() >= 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_profile_tab_renders_for_owner(bob):
|
|
|
|
|
page, user = bob
|
|
|
|
|
page.goto(
|
|
|
|
|
f"{BASE_URL}/profile/{user['username']}?tab=notifications",
|
|
|
|
|
wait_until="domcontentloaded",
|
|
|
|
|
)
|
|
|
|
|
expect(page.locator("[data-notif-prefs]")).to_be_visible()
|
|
|
|
|
expect(
|
|
|
|
|
page.locator("[data-notif-toggle][data-type='vote'][data-channel='push']")
|
|
|
|
|
).to_have_count(1)
|
|
|
|
|
assert page.is_visible("text=Direct messages")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_owner_toggle_via_http_persists(bob):
|
|
|
|
|
_, user = bob
|
|
|
|
|
target = _user_notification_prefs(user["username"])
|
|
|
|
|
reset_notification_prefs(target["uid"])
|
|
|
|
|
try:
|
|
|
|
|
response = requests.post(
|
|
|
|
|
f"{BASE_URL}/profile/{target['username']}/notifications",
|
|
|
|
|
data={"notification_type": "mention", "channel": "push", "value": "false"},
|
|
|
|
|
headers={"Accept": "application/json", "X-API-KEY": target["api_key"]},
|
|
|
|
|
allow_redirects=False,
|
|
|
|
|
)
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert response.json()["ok"] is True
|
|
|
|
|
assert notification_enabled(target["uid"], "mention", "push") is False
|
|
|
|
|
assert notification_enabled(target["uid"], "mention", "in_app") is True
|
|
|
|
|
finally:
|
|
|
|
|
reset_notification_prefs(target["uid"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_non_owner_non_admin_forbidden(bob):
|
|
|
|
|
_, user = bob
|
|
|
|
|
member = _user_notification_prefs(user["username"])
|
|
|
|
|
target = _user_notification_prefs("alice_test")
|
|
|
|
|
response = requests.post(
|
|
|
|
|
f"{BASE_URL}/profile/{target['username']}/notifications",
|
|
|
|
|
data={"notification_type": "vote", "channel": "in_app", "value": "false"},
|
|
|
|
|
headers={"Accept": "application/json", "X-API-KEY": member["api_key"]},
|
|
|
|
|
allow_redirects=False,
|
|
|
|
|
)
|
|
|
|
|
assert response.status_code == 403
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_admin_edits_other_user(alice):
|
|
|
|
|
_, admin_user = alice
|
|
|
|
|
admin = _user_notification_prefs(admin_user["username"])
|
|
|
|
|
target = _user_notification_prefs("bob_test")
|
|
|
|
|
reset_notification_prefs(target["uid"])
|
|
|
|
|
try:
|
|
|
|
|
response = requests.post(
|
|
|
|
|
f"{BASE_URL}/profile/{target['username']}/notifications",
|
|
|
|
|
data={"notification_type": "comment", "channel": "push", "value": "false"},
|
|
|
|
|
headers={"Accept": "application/json", "X-API-KEY": admin["api_key"]},
|
|
|
|
|
allow_redirects=False,
|
|
|
|
|
)
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert notification_enabled(target["uid"], "comment", "push") is False
|
|
|
|
|
finally:
|
|
|
|
|
reset_notification_prefs(target["uid"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_profile_search_returns_results(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
resp = page.request.get(f"{BASE_URL}/profile/search?q=bob")
|
|
|
|
|
assert resp.status == 200
|
|
|
|
|
data = resp.json()
|
|
|
|
|
assert any("bob" in u["username"] for u in data["results"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_profile_page_loads(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
|
|
|
|
|
assert page.is_visible(f"text={user['username']}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_profile_stats(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
|
|
|
|
|
assert page.is_visible("text=Posts")
|
|
|
|
|
assert page.is_visible("text=Level")
|
|
|
|
|
assert page.is_visible("text=Stars")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_profile_level_bar(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
|
|
|
|
|
assert page.is_visible("text=Progress to next level")
|
|
|
|
|
level_bar = page.locator(".profile-level-bar .bar")
|
|
|
|
|
assert level_bar.is_visible()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_profile_badges(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
|
|
|
|
|
badges = page.locator(".profile-badge")
|
|
|
|
|
assert badges.count() >= 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_profile_tabs(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
|
|
|
|
|
tabs = ["Posts", "Projects", "Activity"]
|
|
|
|
|
for tab in tabs:
|
|
|
|
|
assert page.is_visible(f"a:has-text('{tab}')")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_profile_posts_tab(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(
|
|
|
|
|
f"{BASE_URL}/profile/{user['username']}?tab=posts",
|
|
|
|
|
wait_until="domcontentloaded",
|
|
|
|
|
)
|
|
|
|
|
assert page.locator(".profile-tab.active:has-text('Posts')").is_visible()
|
|
|
|
|
assert (
|
|
|
|
|
page.locator(".profile-posts .post-card").count() > 0
|
|
|
|
|
or page.locator(".profile-posts .empty-state:has-text('No posts yet.')").count()
|
|
|
|
|
> 0
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_profile_projects_tab_content(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(
|
|
|
|
|
f"{BASE_URL}/profile/{user['username']}?tab=projects",
|
|
|
|
|
wait_until="domcontentloaded",
|
|
|
|
|
)
|
|
|
|
|
assert page.locator(".profile-tab.active:has-text('Projects')").is_visible()
|
|
|
|
|
assert (
|
|
|
|
|
page.locator(".profile-posts .project-card").count() > 0
|
|
|
|
|
or page.locator(
|
|
|
|
|
".profile-posts .empty-state:has-text('No projects yet.')"
|
|
|
|
|
).count()
|
|
|
|
|
> 0
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_profile_info_section(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
|
|
|
|
|
assert page.is_visible("text=Bio")
|
|
|
|
|
assert page.is_visible("text=Location")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_profile_back_link(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
|
|
|
|
|
back = page.locator("a:has-text('Back')").first
|
|
|
|
|
assert back.is_visible()
|
|
|
|
|
back.click()
|
|
|
|
|
page.wait_for_url(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
|
|
|
|
|
|
2026-06-19 00:09:34 +02:00
|
|
|
def test_profile_level_display(alice):
|
2026-06-13 16:32:33 +02:00
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
|
2026-06-19 00:09:34 +02:00
|
|
|
assert page.locator(".profile-level-bar").is_visible()
|
2026-06-13 16:32:33 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_profile_avatar(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
|
|
|
|
|
avatar = page.locator(".avatar-lg")
|
|
|
|
|
assert avatar.is_visible()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_profile_edit_bio(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
|
|
|
|
|
page.evaluate("""
|
|
|
|
|
document.querySelector('textarea[name="bio"]').value = 'Test bio for testing';
|
|
|
|
|
document.querySelector('form[action="/profile/update"]').submit();
|
|
|
|
|
""")
|
|
|
|
|
page.wait_for_url(
|
|
|
|
|
f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded"
|
|
|
|
|
)
|
|
|
|
|
page.locator("text=Test bio for testing").first.wait_for(state="visible")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_profile_edit_location(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
|
|
|
|
|
page.evaluate("""
|
|
|
|
|
document.querySelector('input[name="location"]').value = 'Amsterdam';
|
|
|
|
|
document.querySelector('form[action="/profile/update"]').submit();
|
|
|
|
|
""")
|
|
|
|
|
page.wait_for_url(
|
|
|
|
|
f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded"
|
|
|
|
|
)
|
|
|
|
|
page.locator("text=Amsterdam").first.wait_for(state="visible")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_profile_edit_all_fields(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
|
|
|
|
|
page.evaluate("""
|
|
|
|
|
document.querySelector('textarea[name="bio"]').value = 'Full stack dev';
|
|
|
|
|
document.querySelector('input[name="location"]').value = 'London, UK';
|
|
|
|
|
document.querySelector('input[name="git_link"]').value = 'https://git.example.com/alice';
|
|
|
|
|
document.querySelector('input[name="website"]').value = 'https://alice.example.com';
|
|
|
|
|
document.querySelector('form[action="/profile/update"]').submit();
|
|
|
|
|
""")
|
|
|
|
|
page.wait_for_url(
|
|
|
|
|
f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded"
|
|
|
|
|
)
|
|
|
|
|
page.locator("text=Full stack dev").first.wait_for(state="visible")
|
|
|
|
|
assert page.is_visible("text=London, UK")
|
|
|
|
|
assert page.is_visible("text=https://git.example.com/alice")
|
|
|
|
|
assert page.is_visible("text=https://alice.example.com")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_profile_message_button_self(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
|
|
|
|
|
message_btn = page.locator("a:has-text('Send Message')")
|
|
|
|
|
assert not message_btn.is_visible()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_profile_activity_tab(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(
|
|
|
|
|
f"{BASE_URL}/profile/{user['username']}?tab=activity",
|
|
|
|
|
wait_until="domcontentloaded",
|
|
|
|
|
)
|
|
|
|
|
assert page.locator(".profile-tab.active:has-text('Activity')").is_visible()
|
|
|
|
|
assert (
|
|
|
|
|
page.locator(".profile-posts .activity-card").count() > 0
|
|
|
|
|
or page.locator(
|
|
|
|
|
".profile-posts .empty-state:has-text('No activity yet.')"
|
|
|
|
|
).count()
|
|
|
|
|
> 0
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_profile_followers_json(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
resp = page.request.get(f"{BASE_URL}/profile/{user['username']}/followers")
|
|
|
|
|
assert resp.status == 200
|
|
|
|
|
data = resp.json()
|
|
|
|
|
assert data["username"] == user["username"]
|
|
|
|
|
assert data["mode"] == "followers"
|
|
|
|
|
assert "followers" in data
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_profile_following_json(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
resp = page.request.get(f"{BASE_URL}/profile/{user['username']}/following")
|
|
|
|
|
assert resp.status == 200
|
|
|
|
|
data = resp.json()
|
|
|
|
|
assert data["username"] == user["username"]
|
|
|
|
|
assert data["mode"] == "following"
|
|
|
|
|
assert "following" in data
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_profile_customization_global_toggle(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
|
|
|
|
|
form = page.locator(f"form[action='/profile/{user['username']}/customization/global']")
|
|
|
|
|
if form.count() > 0:
|
|
|
|
|
button = form.locator("button[type='submit']")
|
|
|
|
|
expect(button).to_have_text("Disable")
|
|
|
|
|
button.click()
|
|
|
|
|
page.locator(".dialog-overlay.visible .dialog-confirm").click()
|
|
|
|
|
expect(button).to_have_text("Enable")
|
|
|
|
|
button.click()
|
|
|
|
|
page.locator(".dialog-overlay.visible .dialog-confirm").click()
|
|
|
|
|
expect(button).to_have_text("Disable")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_profile_customization_pagetype_toggle(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
|
|
|
|
|
form = page.locator(f"form[action='/profile/{user['username']}/customization/pagetype']")
|
|
|
|
|
if form.count() > 0:
|
|
|
|
|
button = form.locator("button[type='submit']")
|
|
|
|
|
expect(button).to_have_text("Disable")
|
|
|
|
|
button.click()
|
|
|
|
|
page.locator(".dialog-overlay.visible .dialog-confirm").click()
|
|
|
|
|
expect(button).to_have_text("Enable")
|
|
|
|
|
button.click()
|
|
|
|
|
page.locator(".dialog-overlay.visible .dialog-confirm").click()
|
|
|
|
|
expect(button).to_have_text("Disable")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_profile_page_has_structured_data(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
|
|
|
|
|
scripts = page.locator('script[type="application/ld+json"]')
|
|
|
|
|
count = scripts.count()
|
|
|
|
|
assert count >= 1
|
|
|
|
|
text = scripts.first.text_content()
|
|
|
|
|
assert "ProfilePage" in text or "WebSite" in text
|