2026-05-11 03:14:43 +02:00
|
|
|
import logging
|
|
|
|
|
import random
|
|
|
|
|
import re
|
|
|
|
|
import uuid
|
|
|
|
|
from locust import HttpUser, task, between, events
|
2026-06-14 02:18:06 +02:00
|
|
|
from requests.exceptions import RequestException
|
2026-05-11 03:14:43 +02:00
|
|
|
|
|
|
|
|
logging.basicConfig(
|
|
|
|
|
level=logging.INFO,
|
|
|
|
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
|
|
|
)
|
|
|
|
|
logger = logging.getLogger("locust")
|
|
|
|
|
|
|
|
|
|
SEED_USERS = []
|
|
|
|
|
ALL_USERNAMES = []
|
|
|
|
|
USER_UIDS = []
|
|
|
|
|
POST_UIDS = []
|
2026-05-11 20:49:45 +02:00
|
|
|
POST_SLUGS = []
|
2026-05-11 03:14:43 +02:00
|
|
|
PROJECT_UIDS = []
|
2026-05-23 05:57:05 +02:00
|
|
|
PROJECT_SLUGS = []
|
2026-05-11 03:14:43 +02:00
|
|
|
COMMENT_UIDS = []
|
2026-05-12 12:45:52 +02:00
|
|
|
NEWS_UIDS = []
|
|
|
|
|
NEWS_SLUGS = []
|
2026-05-23 05:57:05 +02:00
|
|
|
GIST_SLUGS = []
|
|
|
|
|
GIST_UIDS = []
|
2026-06-14 09:48:10 +02:00
|
|
|
ISSUE_UIDS = []
|
|
|
|
|
ISSUE_JOB_UIDS = []
|
2026-05-23 05:57:05 +02:00
|
|
|
NOTIFICATION_UIDS = []
|
2026-06-14 02:36:10 +02:00
|
|
|
SEO_JOB_UIDS = []
|
2026-06-14 01:31:28 +02:00
|
|
|
POLLS = []
|
2026-05-23 05:57:05 +02:00
|
|
|
ADMIN_USER = {}
|
|
|
|
|
ADMIN_TARGETS = {}
|
2026-05-11 03:14:43 +02:00
|
|
|
TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random"]
|
|
|
|
|
PROJECT_TYPES = ["game", "game_asset", "software", "mobile_app", "website"]
|
2026-05-23 05:57:05 +02:00
|
|
|
GIST_LANGUAGES = ["python", "javascript", "go", "rust", "bash", "sql", "json"]
|
2026-07-26 14:57:18 +02:00
|
|
|
REACTION_EMOJIS = [
|
|
|
|
|
"\U0001f44d",
|
|
|
|
|
"\U0001f680",
|
|
|
|
|
"\U0001f389",
|
|
|
|
|
"\U0001f525",
|
|
|
|
|
"\U0001f984",
|
|
|
|
|
"\U0001f44d\U0001f3ff",
|
|
|
|
|
"\U0001f9d1\u200d\U0001f4bb",
|
|
|
|
|
]
|
2026-06-14 01:31:28 +02:00
|
|
|
NOTIFICATION_TYPES = ["comment", "reply", "vote", "follow", "message"]
|
|
|
|
|
DOCS_SLUGS = [
|
|
|
|
|
"index",
|
|
|
|
|
"getting-started",
|
|
|
|
|
"devii",
|
|
|
|
|
"components",
|
|
|
|
|
"dashboard",
|
|
|
|
|
"claude",
|
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",
|
|
|
|
|
"community-guidelines",
|
|
|
|
|
"privacy",
|
|
|
|
|
"content-moderation",
|
|
|
|
|
"intellectual-property",
|
|
|
|
|
"contact",
|
2026-06-14 01:31:28 +02:00
|
|
|
]
|
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
|
|
|
ADMIN_DOCS_SLUGS = ["moderation-operations"]
|
2026-05-23 05:57:05 +02:00
|
|
|
UPLOAD_FILE = ("load_test.txt", b"locust upload payload", "text/plain")
|
2026-06-09 18:48:08 +02:00
|
|
|
UUID_RE = r"[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}"
|
2026-05-11 03:14:43 +02:00
|
|
|
PASSWORD = "testpass123"
|
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 = "01/01/1990"
|
|
|
|
|
ACCEPT_TERMS = "on"
|
|
|
|
|
REPORT_STATUSES = ["open", "acknowledged", "actioned", "dismissed"]
|
2026-05-11 03:14:43 +02:00
|
|
|
|
2026-06-14 01:31:28 +02:00
|
|
|
# ── Deliberately NOT load tested (kept out by design) ───────────────
|
|
|
|
|
# /openai/v1/*, /devii/clippy/ai/chat - real upstream AI spend / billing.
|
|
|
|
|
# /p/{slug}, /projects/.../containers/*, /admin/containers/*
|
|
|
|
|
# - drive the host docker daemon.
|
2026-06-14 09:48:10 +02:00
|
|
|
# /issues/{number}, /issues/{number}/comment, /issues/{number}/status
|
2026-06-14 01:31:28 +02:00
|
|
|
# - live external Gitea REST calls.
|
2026-06-14 02:36:10 +02:00
|
|
|
# /tools/seo/run - launches a real Playwright crawl of
|
|
|
|
|
# an external URL, per-owner rate-capped
|
|
|
|
|
# (one active job) and resource-heavy;
|
|
|
|
|
# enqueuing real audit jobs is unsafe to
|
|
|
|
|
# load test. The read surface (page +
|
|
|
|
|
# status/report lookups) IS covered below.
|
|
|
|
|
# /tools/seo/{uid}/ws - WebSocket; HttpUser cannot drive it.
|
2026-06-14 01:31:28 +02:00
|
|
|
# /admin/ai-quota/reset-guests|reset-all - global destructive quota wipe.
|
|
|
|
|
# /admin/services/{name}/run|start|stop|config|clear-logs
|
|
|
|
|
# - toggles real background services
|
|
|
|
|
# (news grading bills AI; could
|
|
|
|
|
# destabilize the load target).
|
|
|
|
|
# /admin/trash/.../purge, /admin/media/{uid}/purge of seeded content
|
|
|
|
|
# - hard GC of real content (only the
|
|
|
|
|
# dedicated disposable media is purged).
|
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
|
|
|
# POST /reports/{target_type}/{target_uid}
|
|
|
|
|
# - floods the real moderation queue
|
|
|
|
|
# with junk reports. Exactly one
|
|
|
|
|
# disposable report is seeded below so
|
|
|
|
|
# the queue read tasks have a live uid.
|
|
|
|
|
# /admin/moderation/{uid}/decide|status, /admin/users/{uid}/suspend|lift|ban
|
|
|
|
|
# - real enforcement against real
|
|
|
|
|
# accounts. The read surface (queue +
|
|
|
|
|
# report detail) IS covered below.
|
|
|
|
|
# /profile/{username}/delete - destroys accounts (GET and POST).
|
|
|
|
|
# /profile/{username}/consent, /mature-content
|
|
|
|
|
# - flips real consent state.
|
|
|
|
|
# /auth/accept-terms - mutates real acceptance records.
|
2026-06-14 01:31:28 +02:00
|
|
|
# WebSockets (/devii/ws, exec ws) - HttpUser cannot drive them.
|
|
|
|
|
|
2026-05-11 03:14:43 +02:00
|
|
|
|
|
|
|
|
def random_username():
|
|
|
|
|
return f"lu_{uuid.uuid4().hex[:10]}"
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 02:18:06 +02:00
|
|
|
def random_client_ip():
|
|
|
|
|
return "10.%d.%d.%d" % (
|
|
|
|
|
random.randint(0, 255),
|
|
|
|
|
random.randint(0, 255),
|
|
|
|
|
random.randint(1, 254),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def assign_client_ip(client):
|
|
|
|
|
client.headers["X-Real-IP"] = random_client_ip()
|
|
|
|
|
|
|
|
|
|
|
2026-05-11 03:14:43 +02:00
|
|
|
def do_signup(client, username, email):
|
2026-06-09 18:48:08 +02:00
|
|
|
with client.post(
|
|
|
|
|
"/auth/signup",
|
|
|
|
|
data={
|
|
|
|
|
"username": username,
|
|
|
|
|
"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": BIRTH_DATE,
|
|
|
|
|
"accept_terms": ACCEPT_TERMS,
|
2026-06-09 18:48:08 +02:00
|
|
|
},
|
|
|
|
|
catch_response=True,
|
|
|
|
|
name="signup",
|
|
|
|
|
) as resp:
|
2026-05-11 03:14:43 +02:00
|
|
|
if resp.status_code == 200 and resp.history:
|
|
|
|
|
resp.success()
|
|
|
|
|
return True
|
|
|
|
|
resp.failure(f"signup failed: status={resp.status_code}")
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def do_login(client, email):
|
2026-06-09 18:48:08 +02:00
|
|
|
with client.post(
|
|
|
|
|
"/auth/login",
|
|
|
|
|
data={
|
|
|
|
|
"email": email,
|
|
|
|
|
"password": PASSWORD,
|
|
|
|
|
},
|
|
|
|
|
catch_response=True,
|
|
|
|
|
name="login",
|
|
|
|
|
) as resp:
|
2026-05-11 03:14:43 +02:00
|
|
|
if resp.status_code == 200 and resp.history:
|
|
|
|
|
resp.success()
|
|
|
|
|
return True
|
|
|
|
|
resp.failure(f"login failed: status={resp.status_code}")
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@events.init.add_listener
|
|
|
|
|
def seed_data(environment, **kwargs):
|
|
|
|
|
host = environment.host or "http://127.0.0.1:10502"
|
|
|
|
|
logger.info(f"Seeding {host}...")
|
|
|
|
|
|
|
|
|
|
import urllib.request
|
|
|
|
|
import urllib.parse
|
|
|
|
|
import http.cookiejar
|
|
|
|
|
|
|
|
|
|
created = 0
|
2026-06-05 17:44:12 +02:00
|
|
|
attempts = 0
|
|
|
|
|
max_attempts = 25
|
|
|
|
|
while created < 5 and attempts < max_attempts:
|
|
|
|
|
attempts += 1
|
2026-05-11 03:14:43 +02:00
|
|
|
username = random_username()
|
|
|
|
|
email = f"{username}@locust.devplace"
|
2026-06-09 18:48:08 +02:00
|
|
|
body = urllib.parse.urlencode(
|
|
|
|
|
{
|
|
|
|
|
"username": username,
|
|
|
|
|
"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": BIRTH_DATE,
|
|
|
|
|
"accept_terms": ACCEPT_TERMS,
|
2026-06-09 18:48:08 +02:00
|
|
|
}
|
|
|
|
|
).encode()
|
2026-06-14 02:18:06 +02:00
|
|
|
req = urllib.request.Request(
|
|
|
|
|
f"{host}/auth/signup", data=body, headers={"X-Real-IP": random_client_ip()}
|
|
|
|
|
)
|
2026-05-11 03:14:43 +02:00
|
|
|
try:
|
|
|
|
|
urllib.request.urlopen(req, timeout=10)
|
|
|
|
|
SEED_USERS.append({"username": username, "email": email})
|
|
|
|
|
ALL_USERNAMES.append(username)
|
|
|
|
|
created += 1
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(f"Create seed user failed: {e}")
|
|
|
|
|
|
2026-06-05 17:44:12 +02:00
|
|
|
if created < 5:
|
|
|
|
|
logger.error(
|
|
|
|
|
f"Seeding aborted: only {created}/5 users created after "
|
|
|
|
|
f"{attempts} attempts. Is the server healthy at {host}?"
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
2026-05-11 03:14:43 +02:00
|
|
|
logger.info(f"Created {len(SEED_USERS)} seed users")
|
|
|
|
|
|
|
|
|
|
def logged_in_opener(email):
|
|
|
|
|
jar = http.cookiejar.CookieJar()
|
2026-06-09 18:48:08 +02:00
|
|
|
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
2026-06-14 02:18:06 +02:00
|
|
|
opener.addheaders = [("X-Real-IP", random_client_ip())]
|
2026-05-11 03:14:43 +02:00
|
|
|
body = urllib.parse.urlencode({"email": email, "password": PASSWORD}).encode()
|
2026-06-09 18:48:08 +02:00
|
|
|
opener.open(urllib.request.Request(f"{host}/auth/login", data=body), timeout=10)
|
2026-05-11 03:14:43 +02:00
|
|
|
return opener
|
|
|
|
|
|
|
|
|
|
for seed in SEED_USERS[:3]:
|
|
|
|
|
try:
|
|
|
|
|
opener = logged_in_opener(seed["email"])
|
2026-06-09 18:48:08 +02:00
|
|
|
body = urllib.parse.urlencode(
|
2026-06-14 01:31:28 +02:00
|
|
|
[
|
|
|
|
|
("content", "x " * 100),
|
|
|
|
|
("title", f"Seed post {uuid.uuid4().hex[:6]}"),
|
|
|
|
|
("topic", random.choice(TOPICS)),
|
|
|
|
|
("poll_question", f"Seed poll {uuid.uuid4().hex[:4]}?"),
|
|
|
|
|
("poll_options", "alpha"),
|
|
|
|
|
("poll_options", "beta"),
|
|
|
|
|
("poll_options", "gamma"),
|
|
|
|
|
]
|
2026-06-09 18:48:08 +02:00
|
|
|
).encode()
|
2026-05-11 03:14:43 +02:00
|
|
|
resp = opener.open(
|
|
|
|
|
urllib.request.Request(f"{host}/posts/create", data=body),
|
|
|
|
|
timeout=10,
|
|
|
|
|
)
|
2026-05-11 20:49:45 +02:00
|
|
|
m = re.search(r"/posts/(\S+)", resp.url)
|
|
|
|
|
if m and m.group(1) not in POST_SLUGS:
|
|
|
|
|
POST_SLUGS.append(m.group(1))
|
|
|
|
|
html = resp.read().decode("utf-8", errors="replace")
|
2026-06-09 18:48:08 +02:00
|
|
|
m2 = re.search(rf"/votes/post/({UUID_RE})", html)
|
2026-05-11 20:49:45 +02:00
|
|
|
if m2 and m2.group(1) not in POST_UIDS:
|
|
|
|
|
POST_UIDS.append(m2.group(1))
|
2026-05-11 03:14:43 +02:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(f"Create seed post failed: {e}")
|
|
|
|
|
|
|
|
|
|
logger.info(f"Created {len(POST_UIDS)} seed posts")
|
|
|
|
|
|
2026-06-05 19:32:46 +02:00
|
|
|
for post_uid in POST_UIDS:
|
|
|
|
|
for seed in SEED_USERS[:2]:
|
|
|
|
|
try:
|
|
|
|
|
opener = logged_in_opener(seed["email"])
|
2026-06-09 18:48:08 +02:00
|
|
|
body = urllib.parse.urlencode(
|
|
|
|
|
{
|
|
|
|
|
"content": f"Seed comment by {seed['username']}",
|
|
|
|
|
"post_uid": post_uid,
|
|
|
|
|
}
|
|
|
|
|
).encode()
|
2026-06-05 19:32:46 +02:00
|
|
|
opener.open(
|
|
|
|
|
urllib.request.Request(f"{host}/comments/create", data=body),
|
|
|
|
|
timeout=10,
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(f"Create seed comment failed: {e}")
|
2026-05-11 03:14:43 +02:00
|
|
|
|
|
|
|
|
for seed in SEED_USERS[:2]:
|
|
|
|
|
try:
|
|
|
|
|
opener = logged_in_opener(seed["email"])
|
2026-06-09 18:48:08 +02:00
|
|
|
body = urllib.parse.urlencode(
|
|
|
|
|
{
|
|
|
|
|
"title": f"Project {uuid.uuid4().hex[:6]}",
|
|
|
|
|
"description": "z " * 50,
|
|
|
|
|
"project_type": random.choice(PROJECT_TYPES),
|
|
|
|
|
}
|
|
|
|
|
).encode()
|
2026-05-11 03:14:43 +02:00
|
|
|
opener.open(
|
|
|
|
|
urllib.request.Request(f"{host}/projects/create", data=body),
|
|
|
|
|
timeout=10,
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(f"Create seed project failed: {e}")
|
|
|
|
|
|
2026-05-23 05:57:05 +02:00
|
|
|
for seed in SEED_USERS[:2]:
|
|
|
|
|
try:
|
|
|
|
|
opener = logged_in_opener(seed["email"])
|
2026-06-09 18:48:08 +02:00
|
|
|
body = urllib.parse.urlencode(
|
|
|
|
|
{
|
|
|
|
|
"title": f"Gist {uuid.uuid4().hex[:6]}",
|
|
|
|
|
"description": "Seed gist for load testing.",
|
|
|
|
|
"source_code": "print('hello')\n" * 5,
|
|
|
|
|
"language": random.choice(GIST_LANGUAGES),
|
|
|
|
|
}
|
|
|
|
|
).encode()
|
2026-05-23 05:57:05 +02:00
|
|
|
resp = opener.open(
|
|
|
|
|
urllib.request.Request(f"{host}/gists/create", data=body),
|
|
|
|
|
timeout=10,
|
|
|
|
|
)
|
|
|
|
|
m = re.search(r"/gists/(\S+)", resp.url)
|
|
|
|
|
if m and m.group(1) not in GIST_SLUGS:
|
|
|
|
|
GIST_SLUGS.append(m.group(1))
|
|
|
|
|
html = resp.read().decode("utf-8", errors="replace")
|
2026-06-09 18:48:08 +02:00
|
|
|
m2 = re.search(rf"/votes/gist/({UUID_RE})", html)
|
2026-05-23 05:57:05 +02:00
|
|
|
if m2 and m2.group(1) not in GIST_UIDS:
|
|
|
|
|
GIST_UIDS.append(m2.group(1))
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(f"Create seed gist failed: {e}")
|
|
|
|
|
|
|
|
|
|
logger.info(f"Created {len(GIST_SLUGS)} seed gists")
|
|
|
|
|
|
2026-06-02 23:17:51 +02:00
|
|
|
for seed in SEED_USERS[:2]:
|
|
|
|
|
try:
|
|
|
|
|
opener = logged_in_opener(seed["email"])
|
2026-06-09 18:48:08 +02:00
|
|
|
body = urllib.parse.urlencode(
|
|
|
|
|
{
|
2026-06-14 09:48:10 +02:00
|
|
|
"title": f"Issue {uuid.uuid4().hex[:6]}",
|
|
|
|
|
"description": "Seed issue report for load testing.",
|
2026-06-09 18:48:08 +02:00
|
|
|
}
|
|
|
|
|
).encode()
|
2026-06-02 23:17:51 +02:00
|
|
|
opener.open(
|
2026-06-14 09:48:10 +02:00
|
|
|
urllib.request.Request(f"{host}/issues/create", data=body),
|
2026-06-02 23:17:51 +02:00
|
|
|
timeout=10,
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
2026-06-14 09:48:10 +02:00
|
|
|
logger.warning(f"Create seed issue failed: {e}")
|
2026-06-02 23:17:51 +02:00
|
|
|
|
2026-05-12 12:45:52 +02:00
|
|
|
# ── Seed news articles (direct DB insert) ───────────────────
|
|
|
|
|
try:
|
|
|
|
|
from devplacepy.database import get_table, init_db
|
|
|
|
|
from devplacepy.utils import make_combined_slug
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-05-12 12:45:52 +02:00
|
|
|
init_db()
|
|
|
|
|
news_table = get_table("news")
|
|
|
|
|
for i in range(3):
|
|
|
|
|
title = f"Locust news article {i} {uuid.uuid4().hex[:4]}"
|
|
|
|
|
article_uid = str(uuid.uuid4())
|
|
|
|
|
slug = make_combined_slug(title, article_uid)
|
2026-06-09 18:48:08 +02:00
|
|
|
news_table.insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": article_uid,
|
|
|
|
|
"slug": slug,
|
|
|
|
|
"title": title,
|
|
|
|
|
"external_id": f"locust_seed_{i}",
|
|
|
|
|
"grade": 8,
|
|
|
|
|
"status": "published",
|
|
|
|
|
"show_on_landing": 1,
|
|
|
|
|
"source_name": "Locust",
|
|
|
|
|
"synced_at": "2026-01-01T00:00:00",
|
|
|
|
|
"description": "Locust seed article for load testing.",
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-05-12 12:45:52 +02:00
|
|
|
NEWS_UIDS.append(article_uid)
|
|
|
|
|
NEWS_SLUGS.append(slug)
|
2026-05-23 05:57:05 +02:00
|
|
|
|
|
|
|
|
admin_news_uid = str(uuid.uuid4())
|
|
|
|
|
admin_news_title = f"Admin target news {uuid.uuid4().hex[:4]}"
|
2026-06-09 18:48:08 +02:00
|
|
|
news_table.insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": admin_news_uid,
|
|
|
|
|
"slug": make_combined_slug(admin_news_title, admin_news_uid),
|
|
|
|
|
"title": admin_news_title,
|
|
|
|
|
"external_id": f"locust_admin_target_{uuid.uuid4().hex[:6]}",
|
|
|
|
|
"grade": 5,
|
|
|
|
|
"status": "published",
|
|
|
|
|
"show_on_landing": 0,
|
|
|
|
|
"source_name": "Locust",
|
|
|
|
|
"synced_at": "2026-01-01T00:00:00",
|
|
|
|
|
"description": "Dedicated target for admin news mutations.",
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-05-23 05:57:05 +02:00
|
|
|
ADMIN_TARGETS["news_uid"] = admin_news_uid
|
2026-05-12 12:45:52 +02:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(f"Seed news articles failed: {e}")
|
|
|
|
|
|
2026-06-14 02:36:10 +02:00
|
|
|
# ── Harvest existing SEO audit job uids (never enqueue) ─────
|
|
|
|
|
# The heavy POST /tools/seo/run is deliberately excluded (real
|
|
|
|
|
# Playwright crawl, rate-capped), so we never create jobs here.
|
|
|
|
|
# We only pick up any pre-existing seo job rows so the status/report
|
|
|
|
|
# read tasks can hit a live uid when one happens to exist.
|
|
|
|
|
try:
|
|
|
|
|
from devplacepy.services.jobs import queue
|
|
|
|
|
|
|
|
|
|
for job in queue.list_jobs(kind="seo")[:10]:
|
|
|
|
|
uid = job.get("uid")
|
|
|
|
|
if uid and uid not in SEO_JOB_UIDS:
|
|
|
|
|
SEO_JOB_UIDS.append(uid)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(f"Harvest SEO job uids failed: {e}")
|
|
|
|
|
|
2026-05-23 05:57:05 +02:00
|
|
|
# ── Seed an admin user (direct DB insert) ───────────────────
|
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/terms_accepted_at are mandatory: the terms-acceptance
|
|
|
|
|
# middleware redirects every mutating request of a user whose version
|
|
|
|
|
# is stale to /auth/accept-terms, which would silently gut every
|
|
|
|
|
# admin write task. age_band keeps the maturity gates open.
|
2026-05-23 05:57:05 +02:00
|
|
|
try:
|
|
|
|
|
from datetime import datetime, timezone
|
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
|
|
|
from devplacepy.database import get_setting, get_table, init_db
|
2026-05-23 05:57:05 +02:00
|
|
|
from devplacepy.utils import hash_password, generate_uid
|
2026-06-09 18:48:08 +02:00
|
|
|
|
2026-05-23 05:57:05 +02:00
|
|
|
init_db()
|
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
|
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
|
|
|
terms_version = get_setting("terms_version", "1") or "1"
|
2026-05-23 05:57:05 +02:00
|
|
|
username = f"lu_admin_{uuid.uuid4().hex[:6]}"
|
|
|
|
|
email = f"{username}@locust.devplace"
|
2026-06-09 18:48:08 +02:00
|
|
|
get_table("users").insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": generate_uid(),
|
|
|
|
|
"username": username,
|
|
|
|
|
"email": email,
|
|
|
|
|
"password_hash": hash_password(PASSWORD),
|
|
|
|
|
"bio": "",
|
|
|
|
|
"location": "",
|
|
|
|
|
"git_link": "",
|
|
|
|
|
"website": "",
|
|
|
|
|
"role": "Admin",
|
|
|
|
|
"is_active": True,
|
|
|
|
|
"level": 1,
|
|
|
|
|
"xp": 0,
|
|
|
|
|
"stars": 0,
|
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": terms_version,
|
|
|
|
|
"terms_accepted_at": now,
|
|
|
|
|
"age_band": "adult",
|
|
|
|
|
"suspended_until": "",
|
|
|
|
|
"created_at": now,
|
2026-06-09 18:48:08 +02:00
|
|
|
}
|
|
|
|
|
)
|
2026-05-23 05:57:05 +02:00
|
|
|
ADMIN_USER["username"] = username
|
|
|
|
|
ADMIN_USER["email"] = email
|
|
|
|
|
ALL_USERNAMES.append(username)
|
|
|
|
|
|
|
|
|
|
disposable_uid = generate_uid()
|
|
|
|
|
disposable_name = f"lu_target_{uuid.uuid4().hex[:6]}"
|
2026-06-09 18:48:08 +02:00
|
|
|
get_table("users").insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": disposable_uid,
|
|
|
|
|
"username": disposable_name,
|
|
|
|
|
"email": f"{disposable_name}@locust.devplace",
|
|
|
|
|
"password_hash": hash_password(PASSWORD),
|
|
|
|
|
"bio": "",
|
|
|
|
|
"location": "",
|
|
|
|
|
"git_link": "",
|
|
|
|
|
"website": "",
|
|
|
|
|
"role": "Member",
|
|
|
|
|
"is_active": True,
|
|
|
|
|
"level": 1,
|
|
|
|
|
"xp": 0,
|
|
|
|
|
"stars": 0,
|
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": terms_version,
|
|
|
|
|
"terms_accepted_at": now,
|
|
|
|
|
"age_band": "adult",
|
|
|
|
|
"suspended_until": "",
|
|
|
|
|
"created_at": now,
|
2026-06-09 18:48:08 +02:00
|
|
|
}
|
|
|
|
|
)
|
2026-05-23 05:57:05 +02:00
|
|
|
ADMIN_TARGETS["disposable_uid"] = disposable_uid
|
|
|
|
|
logger.info(f"Created admin user {username} and disposable target")
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(f"Seed admin user failed: {e}")
|
|
|
|
|
|
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
|
|
|
# ── Seed one disposable moderation report ───────────────────
|
|
|
|
|
# Filing reports under load is excluded (it would flood the real
|
|
|
|
|
# queue), so exactly one row is raised here, by the disposable user
|
|
|
|
|
# against a seed post, to keep the admin queue/detail read tasks on
|
|
|
|
|
# a live uid instead of an empty pool.
|
|
|
|
|
try:
|
|
|
|
|
from devplacepy.services.moderation import queue as report_queue
|
|
|
|
|
|
|
|
|
|
reporter_uid = ADMIN_TARGETS.get("disposable_uid")
|
|
|
|
|
if reporter_uid and POST_UIDS:
|
|
|
|
|
report = report_queue.raise_report(
|
|
|
|
|
target_type="post",
|
|
|
|
|
target_uid=POST_UIDS[0],
|
|
|
|
|
reporter_uid=reporter_uid,
|
|
|
|
|
reason="spam",
|
|
|
|
|
detail="Disposable report for the moderation queue read tasks.",
|
|
|
|
|
)
|
|
|
|
|
if report:
|
|
|
|
|
ADMIN_TARGETS["report_uid"] = report["uid"]
|
|
|
|
|
logger.info(f"Seeded moderation report {report['uid']}")
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(f"Seed moderation report failed: {e}")
|
|
|
|
|
|
2026-05-11 03:14:43 +02:00
|
|
|
# ── Harvest UIDs from HTML responses ──────────────────────
|
|
|
|
|
# projects sidebar: /projects?user_uid={uuid}
|
|
|
|
|
# project cards: /votes/project/{uuid}
|
|
|
|
|
# comment deletion: /comments/delete/{uuid}
|
|
|
|
|
|
2026-06-09 18:48:08 +02:00
|
|
|
uuid_pat = rf"({UUID_RE})"
|
2026-05-11 03:14:43 +02:00
|
|
|
|
|
|
|
|
for seed in SEED_USERS:
|
|
|
|
|
try:
|
|
|
|
|
opener = logged_in_opener(seed["email"])
|
2026-06-09 18:48:08 +02:00
|
|
|
resp = opener.open(urllib.request.Request(f"{host}/projects"), timeout=10)
|
2026-05-11 03:14:43 +02:00
|
|
|
html = resp.read().decode("utf-8", errors="replace")
|
2026-06-09 18:48:08 +02:00
|
|
|
m = re.search(rf"/projects\?user_uid={uuid_pat}", html)
|
2026-05-11 03:14:43 +02:00
|
|
|
if m and m.group(1) not in USER_UIDS:
|
|
|
|
|
USER_UIDS.append(m.group(1))
|
2026-06-09 18:48:08 +02:00
|
|
|
proj_matches = re.findall(rf"/votes/project/{uuid_pat}", html)
|
|
|
|
|
PROJECT_UIDS.extend(p for p in proj_matches if p not in PROJECT_UIDS)
|
2026-05-23 05:57:05 +02:00
|
|
|
slug_matches = re.findall(r'href="/projects/([^"/?#]+)"', html)
|
|
|
|
|
PROJECT_SLUGS.extend(
|
2026-06-09 18:48:08 +02:00
|
|
|
s
|
|
|
|
|
for s in slug_matches
|
2026-05-23 05:57:05 +02:00
|
|
|
if s not in PROJECT_SLUGS and s not in ("create",)
|
|
|
|
|
)
|
2026-05-11 03:14:43 +02:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(f"Harvest from {seed['username']} failed: {e}")
|
|
|
|
|
|
2026-06-05 19:32:46 +02:00
|
|
|
for post_uid in POST_UIDS:
|
2026-05-11 03:14:43 +02:00
|
|
|
try:
|
|
|
|
|
opener = logged_in_opener(SEED_USERS[0]["email"])
|
|
|
|
|
resp = opener.open(
|
2026-06-05 19:32:46 +02:00
|
|
|
urllib.request.Request(f"{host}/posts/{post_uid}"),
|
2026-05-11 03:14:43 +02:00
|
|
|
timeout=10,
|
|
|
|
|
)
|
|
|
|
|
html = resp.read().decode("utf-8", errors="replace")
|
2026-06-09 18:48:08 +02:00
|
|
|
matches = re.findall(rf"/comments/delete/{uuid_pat}", html)
|
2026-05-11 03:14:43 +02:00
|
|
|
COMMENT_UIDS.extend(m for m in matches if m not in COMMENT_UIDS)
|
2026-06-14 01:31:28 +02:00
|
|
|
poll_uid = re.search(rf'data-poll-uid="{uuid_pat}"', html)
|
|
|
|
|
option_uids = re.findall(rf'data-option-uid="{uuid_pat}"', html)
|
|
|
|
|
if poll_uid and option_uids:
|
|
|
|
|
entry = {"poll_uid": poll_uid.group(1), "options": option_uids}
|
|
|
|
|
if entry not in POLLS:
|
|
|
|
|
POLLS.append(entry)
|
2026-05-11 03:14:43 +02:00
|
|
|
except Exception as e:
|
2026-06-14 01:31:28 +02:00
|
|
|
logger.warning(f"Harvest comment/poll UIDs failed: {e}")
|
2026-05-11 03:14:43 +02:00
|
|
|
|
2026-06-02 23:17:51 +02:00
|
|
|
try:
|
|
|
|
|
opener = logged_in_opener(SEED_USERS[0]["email"])
|
2026-06-14 09:48:10 +02:00
|
|
|
resp = opener.open(urllib.request.Request(f"{host}/issues"), timeout=10)
|
2026-06-02 23:17:51 +02:00
|
|
|
html = resp.read().decode("utf-8", errors="replace")
|
2026-06-14 09:48:10 +02:00
|
|
|
issue_matches = re.findall(rf"/votes/issue/{uuid_pat}", html)
|
|
|
|
|
ISSUE_UIDS.extend(b for b in issue_matches if b not in ISSUE_UIDS)
|
2026-06-02 23:17:51 +02:00
|
|
|
except Exception as e:
|
2026-06-14 09:48:10 +02:00
|
|
|
logger.warning(f"Harvest issue UIDs failed: {e}")
|
2026-06-02 23:17:51 +02:00
|
|
|
|
2026-05-11 03:14:43 +02:00
|
|
|
logger.info(
|
|
|
|
|
f"Seed complete: {len(SEED_USERS)} users, {len(POST_UIDS)} posts, "
|
2026-05-23 05:57:05 +02:00
|
|
|
f"{len(PROJECT_UIDS)} projects ({len(PROJECT_SLUGS)} slugs), "
|
|
|
|
|
f"{len(GIST_UIDS)} gists, {len(COMMENT_UIDS)} comments, "
|
2026-06-14 09:48:10 +02:00
|
|
|
f"{len(ISSUE_UIDS)} issues, {len(USER_UIDS)} uids"
|
2026-05-11 03:14:43 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DevPlaceUser(HttpUser):
|
2026-05-23 05:57:05 +02:00
|
|
|
weight = 20
|
2026-05-11 03:14:43 +02:00
|
|
|
wait_time = between(1, 5)
|
|
|
|
|
|
|
|
|
|
def on_start(self):
|
2026-06-14 02:18:06 +02:00
|
|
|
assign_client_ip(self.client)
|
2026-05-23 05:57:05 +02:00
|
|
|
self.own_post_slugs = []
|
|
|
|
|
self.own_gist_slugs = []
|
|
|
|
|
self.own_project_slugs = []
|
2026-06-14 01:31:28 +02:00
|
|
|
self.own_media_uids = []
|
2026-05-11 03:14:43 +02:00
|
|
|
if random.random() < 0.3 and SEED_USERS:
|
|
|
|
|
seed = random.choice(SEED_USERS)
|
|
|
|
|
do_login(self.client, seed["email"])
|
|
|
|
|
self.username = seed["username"]
|
|
|
|
|
else:
|
|
|
|
|
username = random_username()
|
|
|
|
|
email = f"{username}@locust.devplace"
|
2026-06-14 02:18:06 +02:00
|
|
|
if do_signup(self.client, username, email):
|
|
|
|
|
ALL_USERNAMES.append(username)
|
2026-05-11 03:14:43 +02:00
|
|
|
self.username = username
|
|
|
|
|
|
|
|
|
|
# ── browsing ────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
@task(5)
|
|
|
|
|
def view_feed(self):
|
|
|
|
|
tab = random.choice(["all", "trending", "recent", "following"])
|
|
|
|
|
topic = random.choice([None] + TOPICS)
|
|
|
|
|
params = {"tab": tab}
|
|
|
|
|
if topic:
|
|
|
|
|
params["topic"] = topic
|
2026-06-02 23:17:51 +02:00
|
|
|
resp = self.client.get("/feed", params=params, name="feed")
|
|
|
|
|
cursor = re.search(r'/feed\?before=([^"&]+)', resp.text)
|
|
|
|
|
if cursor:
|
|
|
|
|
params["before"] = cursor.group(1)
|
|
|
|
|
self.client.get("/feed", params=params, name="feed?before")
|
2026-05-11 03:14:43 +02:00
|
|
|
|
|
|
|
|
@task(4)
|
|
|
|
|
def view_post(self):
|
2026-05-11 20:49:45 +02:00
|
|
|
slugs = POST_SLUGS if POST_SLUGS else POST_UIDS
|
|
|
|
|
if not slugs:
|
2026-05-11 03:14:43 +02:00
|
|
|
return
|
2026-06-09 18:48:08 +02:00
|
|
|
self.client.get(f"/posts/{random.choice(slugs)}", name="posts/[uid]")
|
2026-05-11 03:14:43 +02:00
|
|
|
|
|
|
|
|
@task(3)
|
|
|
|
|
def view_profile(self):
|
|
|
|
|
if not ALL_USERNAMES:
|
|
|
|
|
return
|
|
|
|
|
self.client.get(
|
2026-06-02 23:17:51 +02:00
|
|
|
f"/profile/{random.choice(ALL_USERNAMES)}",
|
|
|
|
|
params={"tab": random.choice(["posts", "activity"])},
|
|
|
|
|
name="profile/[username]",
|
2026-05-11 03:14:43 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@task(3)
|
|
|
|
|
def view_projects(self):
|
2026-06-02 23:17:51 +02:00
|
|
|
params = {"tab": random.choice(["recent", "released", "popular", "new"])}
|
|
|
|
|
if random.random() < 0.3:
|
|
|
|
|
params["project_type"] = random.choice(PROJECT_TYPES)
|
|
|
|
|
if random.random() < 0.2 and USER_UIDS:
|
|
|
|
|
params["user_uid"] = random.choice(USER_UIDS)
|
|
|
|
|
self.client.get("/projects", params=params, name="projects")
|
2026-05-11 03:14:43 +02:00
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def view_landing(self):
|
|
|
|
|
self.client.get("/", name="landing")
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def view_auth_forms(self):
|
|
|
|
|
self.client.get(
|
|
|
|
|
random.choice(["/auth/signup", "/auth/login"]), name="auth/form"
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-12 12:45:52 +02:00
|
|
|
@task(3)
|
|
|
|
|
def view_news(self):
|
|
|
|
|
self.client.get("/news", name="news")
|
|
|
|
|
|
|
|
|
|
@task(2)
|
|
|
|
|
def view_news_detail(self):
|
|
|
|
|
slugs = NEWS_SLUGS if NEWS_SLUGS else NEWS_UIDS
|
|
|
|
|
if not slugs:
|
|
|
|
|
return
|
2026-06-09 18:48:08 +02:00
|
|
|
self.client.get(f"/news/{random.choice(slugs)}", name="news/[slug]")
|
2026-05-12 12:45:52 +02:00
|
|
|
|
|
|
|
|
@task(1)
|
2026-06-14 09:48:10 +02:00
|
|
|
def view_issues(self):
|
|
|
|
|
self.client.get("/issues", name="issues")
|
2026-05-12 12:45:52 +02:00
|
|
|
|
2026-05-23 05:57:05 +02:00
|
|
|
@task(3)
|
|
|
|
|
def view_project_detail(self):
|
|
|
|
|
if not PROJECT_SLUGS:
|
|
|
|
|
return
|
|
|
|
|
self.client.get(
|
|
|
|
|
f"/projects/{random.choice(PROJECT_SLUGS)}", name="projects/[slug]"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@task(2)
|
|
|
|
|
def view_gists(self):
|
2026-06-02 23:17:51 +02:00
|
|
|
params = {}
|
|
|
|
|
if random.random() < 0.3:
|
|
|
|
|
params["language"] = random.choice(GIST_LANGUAGES)
|
|
|
|
|
if random.random() < 0.2 and USER_UIDS:
|
|
|
|
|
params["user_uid"] = random.choice(USER_UIDS)
|
|
|
|
|
self.client.get("/gists", params=params, name="gists")
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def view_leaderboard(self):
|
|
|
|
|
self.client.get("/leaderboard", name="leaderboard")
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def view_pwa(self):
|
|
|
|
|
self.client.get(
|
|
|
|
|
random.choice(["/push.json", "/service-worker.js", "/manifest.json"]),
|
|
|
|
|
name="pwa",
|
|
|
|
|
)
|
2026-05-23 05:57:05 +02:00
|
|
|
|
|
|
|
|
@task(2)
|
|
|
|
|
def view_gist_detail(self):
|
|
|
|
|
if not GIST_SLUGS:
|
|
|
|
|
return
|
2026-06-09 18:48:08 +02:00
|
|
|
self.client.get(f"/gists/{random.choice(GIST_SLUGS)}", name="gists/[slug]")
|
2026-05-23 05:57:05 +02:00
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def search_profiles(self):
|
|
|
|
|
term = random.choice(ALL_USERNAMES)[:5] if ALL_USERNAMES else "lu"
|
2026-06-09 18:48:08 +02:00
|
|
|
self.client.get("/profile/search", params={"q": term}, name="profile/search")
|
2026-05-23 05:57:05 +02:00
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def search_messages(self):
|
|
|
|
|
term = random.choice(ALL_USERNAMES)[:5] if ALL_USERNAMES else "lu"
|
2026-06-09 18:48:08 +02:00
|
|
|
self.client.get("/messages/search", params={"q": term}, name="messages/search")
|
2026-05-23 05:57:05 +02:00
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def view_seo(self):
|
2026-06-09 18:48:08 +02:00
|
|
|
self.client.get(random.choice(["/robots.txt", "/sitemap.xml"]), name="seo")
|
2026-05-23 05:57:05 +02:00
|
|
|
|
2026-06-14 02:36:10 +02:00
|
|
|
@task(2)
|
|
|
|
|
def view_seo_diagnostics(self):
|
|
|
|
|
self.client.get("/tools", name="tools")
|
|
|
|
|
self.client.get("/tools/seo", name="tools/seo")
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def view_seo_audit_status(self):
|
|
|
|
|
uid = random.choice(SEO_JOB_UIDS) if SEO_JOB_UIDS else str(uuid.uuid4())
|
|
|
|
|
with self.client.get(
|
|
|
|
|
f"/tools/seo/{uid}", catch_response=True, name="tools/seo/[uid]"
|
|
|
|
|
) as resp:
|
|
|
|
|
if resp.status_code in (200, 404):
|
|
|
|
|
resp.success()
|
|
|
|
|
else:
|
|
|
|
|
resp.failure(f"seo status: status={resp.status_code}")
|
|
|
|
|
with self.client.get(
|
|
|
|
|
f"/tools/seo/{uid}/report",
|
|
|
|
|
catch_response=True,
|
|
|
|
|
name="tools/seo/[uid]/report",
|
|
|
|
|
) as report:
|
|
|
|
|
if report.status_code in (200, 404):
|
|
|
|
|
report.success()
|
|
|
|
|
else:
|
|
|
|
|
report.failure(f"seo report: status={report.status_code}")
|
|
|
|
|
|
2026-05-23 05:57:05 +02:00
|
|
|
@task(1)
|
|
|
|
|
def view_static(self):
|
|
|
|
|
self.client.get("/static/css/base.css", name="static")
|
|
|
|
|
|
2026-06-14 01:31:28 +02:00
|
|
|
@task(2)
|
|
|
|
|
def view_docs(self):
|
|
|
|
|
if random.random() < 0.2:
|
|
|
|
|
self.client.get(
|
|
|
|
|
random.choice(["/docs", "/docs/download.md", "/docs/download.html"]),
|
|
|
|
|
name="docs",
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
self.client.get(
|
|
|
|
|
f"/docs/{random.choice(DOCS_SLUGS)}.html", name="docs/[slug]"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def view_devii_pages(self):
|
|
|
|
|
self.client.get(
|
|
|
|
|
random.choice(["/devii/", "/devii/session", "/devii/usage"]),
|
|
|
|
|
name="devii/[page]",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def view_devii_adopt(self):
|
|
|
|
|
self.client.get("/devii/adopt", params={"next": "/feed"}, name="devii/adopt")
|
|
|
|
|
|
|
|
|
|
@task(2)
|
|
|
|
|
def view_followers(self):
|
|
|
|
|
if not ALL_USERNAMES:
|
|
|
|
|
return
|
|
|
|
|
username = random.choice(ALL_USERNAMES)
|
|
|
|
|
self.client.get(
|
|
|
|
|
f"/profile/{username}/{random.choice(['followers', 'following'])}",
|
|
|
|
|
name="profile/[username]/follows",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@task(2)
|
|
|
|
|
def view_notification_counts(self):
|
|
|
|
|
self.client.get("/notifications/counts", name="notifications/counts")
|
|
|
|
|
|
|
|
|
|
@task(2)
|
|
|
|
|
def view_saved(self):
|
|
|
|
|
self.client.get("/bookmarks/saved", name="bookmarks/saved")
|
|
|
|
|
|
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
|
|
|
@task(1)
|
|
|
|
|
def view_report_reasons(self):
|
|
|
|
|
self.client.get("/reports/reasons", name="reports/reasons")
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def view_my_reports(self):
|
|
|
|
|
params = {}
|
|
|
|
|
if random.random() < 0.5:
|
|
|
|
|
params["status"] = random.choice(REPORT_STATUSES)
|
|
|
|
|
self.client.get("/reports/mine", params=params, name="reports/mine")
|
|
|
|
|
|
|
|
|
|
@task(2)
|
|
|
|
|
def view_workspace_index(self):
|
|
|
|
|
self.client.get("/workspaces/index", name="workspaces/index")
|
|
|
|
|
|
2026-05-11 03:14:43 +02:00
|
|
|
# ── auth flows ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def login_flow(self):
|
|
|
|
|
if not SEED_USERS:
|
|
|
|
|
return
|
|
|
|
|
do_login(self.client, random.choice(SEED_USERS)["email"])
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def logout_and_relogin(self):
|
|
|
|
|
self.client.get("/auth/logout", name="auth/logout")
|
|
|
|
|
self.client.get("/", name="landing")
|
|
|
|
|
if SEED_USERS:
|
|
|
|
|
do_login(self.client, random.choice(SEED_USERS)["email"])
|
|
|
|
|
|
2026-05-23 05:57:05 +02:00
|
|
|
@task(1)
|
|
|
|
|
def view_forgot_password(self):
|
|
|
|
|
self.client.get("/auth/forgot-password", name="auth/forgot-password")
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def request_password_reset(self):
|
2026-06-09 18:48:08 +02:00
|
|
|
email = (
|
|
|
|
|
random.choice(SEED_USERS)["email"] if SEED_USERS else "x@locust.devplace"
|
|
|
|
|
)
|
2026-05-23 05:57:05 +02:00
|
|
|
self.client.post(
|
2026-06-09 18:48:08 +02:00
|
|
|
"/auth/forgot-password",
|
|
|
|
|
data={"email": email},
|
2026-05-23 05:57:05 +02:00
|
|
|
name="auth/forgot-password/post",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def reset_password_flow(self):
|
|
|
|
|
token = uuid.uuid4().hex + uuid.uuid4().hex
|
2026-06-09 18:48:08 +02:00
|
|
|
self.client.get(f"/auth/reset-password/{token}", name="auth/reset-password")
|
2026-05-23 05:57:05 +02:00
|
|
|
self.client.post(
|
|
|
|
|
f"/auth/reset-password/{token}",
|
|
|
|
|
data={"password": PASSWORD, "confirm_password": PASSWORD},
|
|
|
|
|
name="auth/reset-password/post",
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-11 03:14:43 +02:00
|
|
|
# ── content creation ────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
@task(2)
|
|
|
|
|
def create_post(self):
|
|
|
|
|
data = {
|
|
|
|
|
"content": "x " * 100,
|
|
|
|
|
"title": f"Load test {uuid.uuid4().hex[:6]}",
|
|
|
|
|
"topic": random.choice(TOPICS),
|
|
|
|
|
}
|
2026-06-09 18:48:08 +02:00
|
|
|
with self.client.post(
|
|
|
|
|
"/posts/create", data=data, catch_response=True, name="posts/create"
|
|
|
|
|
) as resp:
|
2026-05-11 03:14:43 +02:00
|
|
|
if resp.status_code == 200 and resp.history:
|
2026-06-14 16:46:36 +02:00
|
|
|
m = re.search(r"/posts/(\S+)", resp.url or "")
|
2026-05-23 05:57:05 +02:00
|
|
|
if m:
|
|
|
|
|
slug = m.group(1)
|
|
|
|
|
if slug not in POST_SLUGS:
|
|
|
|
|
POST_SLUGS.append(slug)
|
|
|
|
|
self.own_post_slugs.append(slug)
|
2026-06-09 18:48:08 +02:00
|
|
|
m2 = re.search(rf"/votes/post/({UUID_RE})", resp.text)
|
2026-05-11 20:49:45 +02:00
|
|
|
if m2 and m2.group(1) not in POST_UIDS:
|
|
|
|
|
POST_UIDS.append(m2.group(1))
|
2026-05-11 03:14:43 +02:00
|
|
|
resp.success()
|
|
|
|
|
else:
|
|
|
|
|
resp.failure("post not created")
|
|
|
|
|
|
|
|
|
|
@task(2)
|
|
|
|
|
def comment_on_post(self):
|
|
|
|
|
if not POST_UIDS:
|
|
|
|
|
return
|
2026-06-09 18:48:08 +02:00
|
|
|
self.client.post(
|
|
|
|
|
"/comments/create",
|
|
|
|
|
data={
|
|
|
|
|
"content": f"Comment {uuid.uuid4().hex[:6]} " * 3,
|
|
|
|
|
"post_uid": random.choice(POST_UIDS),
|
|
|
|
|
},
|
|
|
|
|
name="comments/create",
|
|
|
|
|
)
|
2026-05-11 03:14:43 +02:00
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def create_project(self):
|
2026-06-09 18:48:08 +02:00
|
|
|
with self.client.post(
|
|
|
|
|
"/projects/create",
|
|
|
|
|
data={
|
|
|
|
|
"title": f"Project {uuid.uuid4().hex[:6]}",
|
|
|
|
|
"description": "y " * 50,
|
|
|
|
|
"project_type": random.choice(PROJECT_TYPES),
|
|
|
|
|
},
|
|
|
|
|
catch_response=True,
|
|
|
|
|
name="projects/create",
|
|
|
|
|
) as resp:
|
2026-06-14 16:46:36 +02:00
|
|
|
m = re.search(r"/projects/(\S+)", resp.url or "")
|
2026-05-23 05:57:05 +02:00
|
|
|
if resp.status_code == 200 and m:
|
|
|
|
|
slug = m.group(1)
|
|
|
|
|
if slug not in PROJECT_SLUGS:
|
|
|
|
|
PROJECT_SLUGS.append(slug)
|
|
|
|
|
self.own_project_slugs.append(slug)
|
|
|
|
|
resp.success()
|
|
|
|
|
else:
|
|
|
|
|
resp.failure("project not created")
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def create_gist(self):
|
2026-06-09 18:48:08 +02:00
|
|
|
with self.client.post(
|
|
|
|
|
"/gists/create",
|
|
|
|
|
data={
|
|
|
|
|
"title": f"Gist {uuid.uuid4().hex[:6]}",
|
|
|
|
|
"description": "Load test gist.",
|
|
|
|
|
"source_code": "x = 1\n" * 20,
|
|
|
|
|
"language": random.choice(GIST_LANGUAGES),
|
|
|
|
|
},
|
|
|
|
|
catch_response=True,
|
|
|
|
|
name="gists/create",
|
|
|
|
|
) as resp:
|
2026-06-14 16:46:36 +02:00
|
|
|
m = re.search(r"/gists/(\S+)", resp.url or "")
|
2026-05-23 05:57:05 +02:00
|
|
|
if resp.status_code == 200 and m:
|
|
|
|
|
slug = m.group(1)
|
|
|
|
|
if slug not in GIST_SLUGS:
|
|
|
|
|
GIST_SLUGS.append(slug)
|
|
|
|
|
self.own_gist_slugs.append(slug)
|
2026-06-09 18:48:08 +02:00
|
|
|
m2 = re.search(rf"/votes/gist/({UUID_RE})", resp.text)
|
2026-05-23 05:57:05 +02:00
|
|
|
if m2 and m2.group(1) not in GIST_UIDS:
|
|
|
|
|
GIST_UIDS.append(m2.group(1))
|
|
|
|
|
resp.success()
|
|
|
|
|
else:
|
|
|
|
|
resp.failure("gist not created")
|
|
|
|
|
|
|
|
|
|
@task(1)
|
2026-06-14 09:48:10 +02:00
|
|
|
def create_issue(self):
|
2026-06-14 01:31:28 +02:00
|
|
|
with self.client.post(
|
2026-06-14 09:48:10 +02:00
|
|
|
"/issues/create",
|
2026-06-09 18:48:08 +02:00
|
|
|
data={
|
2026-06-14 09:48:10 +02:00
|
|
|
"title": f"Issue {uuid.uuid4().hex[:6]}",
|
|
|
|
|
"description": "Load test issue report description.",
|
2026-06-09 18:48:08 +02:00
|
|
|
},
|
2026-06-14 01:31:28 +02:00
|
|
|
catch_response=True,
|
2026-06-14 09:48:10 +02:00
|
|
|
name="issues/create",
|
2026-06-14 01:31:28 +02:00
|
|
|
) as resp:
|
|
|
|
|
if resp.status_code == 503:
|
|
|
|
|
resp.success()
|
|
|
|
|
return
|
|
|
|
|
if resp.status_code != 200:
|
2026-06-14 09:48:10 +02:00
|
|
|
resp.failure(f"issue create: status={resp.status_code}")
|
2026-06-14 01:31:28 +02:00
|
|
|
return
|
|
|
|
|
resp.success()
|
|
|
|
|
try:
|
|
|
|
|
uid = resp.json().get("uid")
|
2026-06-14 02:18:06 +02:00
|
|
|
except (ValueError, RequestException, AttributeError):
|
2026-06-14 01:31:28 +02:00
|
|
|
uid = None
|
2026-06-14 09:48:10 +02:00
|
|
|
if uid and uid not in ISSUE_JOB_UIDS:
|
|
|
|
|
ISSUE_JOB_UIDS.append(uid)
|
2026-05-11 03:14:43 +02:00
|
|
|
|
|
|
|
|
# ── social interaction ──────────────────────────────────────
|
|
|
|
|
|
2026-06-02 23:17:51 +02:00
|
|
|
def _vote(self, target_type, uid):
|
|
|
|
|
data = {"value": random.choice([1, -1])}
|
|
|
|
|
name = f"votes/{target_type}"
|
|
|
|
|
if random.random() < 0.5:
|
2026-06-09 18:48:08 +02:00
|
|
|
self.client.post(f"/votes/{target_type}/{uid}", data=data, name=name)
|
2026-06-02 23:17:51 +02:00
|
|
|
return
|
|
|
|
|
with self.client.post(
|
2026-06-09 18:48:08 +02:00
|
|
|
f"/votes/{target_type}/{uid}",
|
|
|
|
|
data=data,
|
2026-06-02 23:17:51 +02:00
|
|
|
headers={"x-requested-with": "fetch"},
|
2026-06-09 18:48:08 +02:00
|
|
|
catch_response=True,
|
|
|
|
|
name=f"{name}/ajax",
|
2026-06-02 23:17:51 +02:00
|
|
|
) as resp:
|
|
|
|
|
try:
|
|
|
|
|
payload = resp.json()
|
2026-06-14 02:18:06 +02:00
|
|
|
except (ValueError, RequestException):
|
2026-06-02 23:17:51 +02:00
|
|
|
resp.failure("vote ajax response not JSON")
|
|
|
|
|
return
|
|
|
|
|
if all(k in payload for k in ("net", "up", "down", "value")):
|
|
|
|
|
resp.success()
|
|
|
|
|
else:
|
|
|
|
|
resp.failure(f"vote ajax missing keys: {payload}")
|
|
|
|
|
|
2026-05-11 03:14:43 +02:00
|
|
|
@task(2)
|
|
|
|
|
def vote_on_post(self):
|
|
|
|
|
if not POST_UIDS:
|
|
|
|
|
return
|
2026-06-02 23:17:51 +02:00
|
|
|
self._vote("post", random.choice(POST_UIDS))
|
2026-05-11 03:14:43 +02:00
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def vote_on_project(self):
|
|
|
|
|
if not PROJECT_UIDS:
|
|
|
|
|
return
|
2026-06-02 23:17:51 +02:00
|
|
|
self._vote("project", random.choice(PROJECT_UIDS))
|
2026-05-11 03:14:43 +02:00
|
|
|
|
2026-05-23 05:57:05 +02:00
|
|
|
@task(1)
|
|
|
|
|
def vote_on_comment(self):
|
|
|
|
|
if not COMMENT_UIDS:
|
|
|
|
|
return
|
2026-06-02 23:17:51 +02:00
|
|
|
self._vote("comment", random.choice(COMMENT_UIDS))
|
2026-05-23 05:57:05 +02:00
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def vote_on_gist(self):
|
|
|
|
|
if not GIST_UIDS:
|
|
|
|
|
return
|
2026-06-02 23:17:51 +02:00
|
|
|
self._vote("gist", random.choice(GIST_UIDS))
|
|
|
|
|
|
|
|
|
|
@task(1)
|
2026-06-14 09:48:10 +02:00
|
|
|
def vote_on_issue(self):
|
|
|
|
|
if not ISSUE_UIDS:
|
2026-06-02 23:17:51 +02:00
|
|
|
return
|
2026-06-14 09:48:10 +02:00
|
|
|
self._vote("issue", random.choice(ISSUE_UIDS))
|
2026-05-23 05:57:05 +02:00
|
|
|
|
2026-06-14 01:31:28 +02:00
|
|
|
def _react(self, target_type, uid):
|
|
|
|
|
emoji = random.choice(REACTION_EMOJIS)
|
|
|
|
|
with self.client.post(
|
|
|
|
|
f"/reactions/{target_type}/{uid}",
|
|
|
|
|
data={"emoji": emoji},
|
|
|
|
|
headers={"x-requested-with": "fetch"},
|
|
|
|
|
catch_response=True,
|
|
|
|
|
name=f"reactions/{target_type}",
|
|
|
|
|
) as resp:
|
|
|
|
|
try:
|
|
|
|
|
payload = resp.json()
|
2026-06-14 02:18:06 +02:00
|
|
|
except (ValueError, RequestException):
|
2026-06-14 01:31:28 +02:00
|
|
|
resp.failure("reaction response not JSON")
|
|
|
|
|
return
|
|
|
|
|
if "counts" in payload and "mine" in payload:
|
|
|
|
|
resp.success()
|
|
|
|
|
else:
|
|
|
|
|
resp.failure(f"reaction missing keys: {payload}")
|
|
|
|
|
|
|
|
|
|
@task(2)
|
|
|
|
|
def react_on_post(self):
|
|
|
|
|
if not POST_UIDS:
|
|
|
|
|
return
|
|
|
|
|
self._react("post", random.choice(POST_UIDS))
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def react_on_target(self):
|
|
|
|
|
pools = []
|
|
|
|
|
if GIST_UIDS:
|
|
|
|
|
pools.append(("gist", GIST_UIDS))
|
|
|
|
|
if PROJECT_UIDS:
|
|
|
|
|
pools.append(("project", PROJECT_UIDS))
|
|
|
|
|
if COMMENT_UIDS:
|
|
|
|
|
pools.append(("comment", COMMENT_UIDS))
|
|
|
|
|
if not pools:
|
|
|
|
|
return
|
|
|
|
|
target_type, uids = random.choice(pools)
|
|
|
|
|
self._react(target_type, random.choice(uids))
|
|
|
|
|
|
|
|
|
|
def _bookmark(self, target_type, uid):
|
|
|
|
|
with self.client.post(
|
|
|
|
|
f"/bookmarks/{target_type}/{uid}",
|
|
|
|
|
headers={"x-requested-with": "fetch"},
|
|
|
|
|
catch_response=True,
|
|
|
|
|
name=f"bookmarks/{target_type}",
|
|
|
|
|
) as resp:
|
|
|
|
|
try:
|
|
|
|
|
payload = resp.json()
|
2026-06-14 02:18:06 +02:00
|
|
|
except (ValueError, RequestException):
|
2026-06-14 01:31:28 +02:00
|
|
|
resp.failure("bookmark response not JSON")
|
|
|
|
|
return
|
|
|
|
|
if "saved" in payload:
|
|
|
|
|
resp.success()
|
|
|
|
|
else:
|
|
|
|
|
resp.failure(f"bookmark missing keys: {payload}")
|
|
|
|
|
|
|
|
|
|
@task(2)
|
|
|
|
|
def bookmark_post(self):
|
|
|
|
|
if not POST_UIDS:
|
|
|
|
|
return
|
|
|
|
|
self._bookmark("post", random.choice(POST_UIDS))
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def bookmark_target(self):
|
|
|
|
|
pools = []
|
|
|
|
|
if GIST_UIDS:
|
|
|
|
|
pools.append(("gist", GIST_UIDS))
|
|
|
|
|
if PROJECT_UIDS:
|
|
|
|
|
pools.append(("project", PROJECT_UIDS))
|
|
|
|
|
if NEWS_UIDS:
|
|
|
|
|
pools.append(("news", NEWS_UIDS))
|
|
|
|
|
if not pools:
|
|
|
|
|
return
|
|
|
|
|
target_type, uids = random.choice(pools)
|
|
|
|
|
self._bookmark(target_type, random.choice(uids))
|
|
|
|
|
|
|
|
|
|
@task(2)
|
|
|
|
|
def vote_on_poll(self):
|
|
|
|
|
if not POLLS:
|
|
|
|
|
return
|
|
|
|
|
poll = random.choice(POLLS)
|
|
|
|
|
with self.client.post(
|
|
|
|
|
f"/polls/{poll['poll_uid']}/vote",
|
|
|
|
|
data={"option_uid": random.choice(poll["options"])},
|
|
|
|
|
headers={"x-requested-with": "fetch"},
|
|
|
|
|
catch_response=True,
|
|
|
|
|
name="polls/[uid]/vote",
|
|
|
|
|
) as resp:
|
|
|
|
|
try:
|
|
|
|
|
payload = resp.json()
|
2026-06-14 02:18:06 +02:00
|
|
|
except (ValueError, RequestException):
|
2026-06-14 01:31:28 +02:00
|
|
|
resp.failure("poll vote response not JSON")
|
|
|
|
|
return
|
|
|
|
|
if "options" in payload or "total" in payload:
|
|
|
|
|
resp.success()
|
|
|
|
|
else:
|
|
|
|
|
resp.failure(f"poll vote missing keys: {payload}")
|
|
|
|
|
|
2026-05-11 03:14:43 +02:00
|
|
|
@task(1)
|
|
|
|
|
def follow_user(self):
|
|
|
|
|
targets = [u for u in ALL_USERNAMES if u != self.username]
|
|
|
|
|
if not targets:
|
|
|
|
|
return
|
2026-06-09 18:48:08 +02:00
|
|
|
self.client.post(f"/follow/{random.choice(targets)}", name="follow/[username]")
|
2026-05-11 03:14:43 +02:00
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def unfollow_user(self):
|
|
|
|
|
targets = [u for u in ALL_USERNAMES if u != self.username]
|
|
|
|
|
if not targets:
|
|
|
|
|
return
|
|
|
|
|
self.client.post(
|
|
|
|
|
f"/follow/unfollow/{random.choice(targets)}", name="follow/unfollow"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def send_message(self):
|
|
|
|
|
if not USER_UIDS or not SEED_USERS:
|
|
|
|
|
return
|
|
|
|
|
target_uid = random.choice(USER_UIDS)
|
2026-06-09 18:48:08 +02:00
|
|
|
self.client.post(
|
|
|
|
|
"/messages/send",
|
|
|
|
|
data={
|
|
|
|
|
"content": f"Msg from {self.username}",
|
|
|
|
|
"receiver_uid": target_uid,
|
|
|
|
|
},
|
|
|
|
|
name="messages/send",
|
|
|
|
|
)
|
2026-05-11 03:14:43 +02:00
|
|
|
|
|
|
|
|
# ── user self-management ────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def update_profile(self):
|
2026-06-09 18:48:08 +02:00
|
|
|
self.client.post(
|
|
|
|
|
"/profile/update",
|
|
|
|
|
data={
|
|
|
|
|
"bio": f"Updated at {uuid.uuid4().hex[:6]}",
|
|
|
|
|
"location": random.choice(["NL", "US", "DE", "UK", " "]),
|
|
|
|
|
},
|
|
|
|
|
name="profile/update",
|
|
|
|
|
)
|
2026-05-11 03:14:43 +02:00
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def view_messages(self):
|
|
|
|
|
self.client.get("/messages", name="messages")
|
|
|
|
|
|
2026-06-02 23:17:51 +02:00
|
|
|
@task(1)
|
|
|
|
|
def view_conversation(self):
|
|
|
|
|
if not USER_UIDS:
|
|
|
|
|
return
|
|
|
|
|
self.client.get(
|
2026-06-09 18:48:08 +02:00
|
|
|
"/messages",
|
|
|
|
|
params={"with_uid": random.choice(USER_UIDS)},
|
2026-06-02 23:17:51 +02:00
|
|
|
name="messages?with_uid",
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-11 03:14:43 +02:00
|
|
|
@task(1)
|
|
|
|
|
def view_notifications(self):
|
2026-05-23 05:57:05 +02:00
|
|
|
resp = self.client.get("/notifications", name="notifications")
|
2026-06-09 18:48:08 +02:00
|
|
|
matches = re.findall(rf"/notifications/mark-read/({UUID_RE})", resp.text)
|
|
|
|
|
NOTIFICATION_UIDS.extend(m for m in matches if m not in NOTIFICATION_UIDS)
|
2026-06-02 23:17:51 +02:00
|
|
|
cursor = re.search(r'/notifications\?before=([^"]+)', resp.text)
|
|
|
|
|
if cursor:
|
|
|
|
|
self.client.get(
|
2026-06-09 18:48:08 +02:00
|
|
|
"/notifications",
|
|
|
|
|
params={"before": cursor.group(1)},
|
2026-06-02 23:17:51 +02:00
|
|
|
name="notifications?before",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def open_notification(self):
|
|
|
|
|
if not NOTIFICATION_UIDS:
|
|
|
|
|
return
|
|
|
|
|
self.client.get(
|
|
|
|
|
f"/notifications/open/{random.choice(NOTIFICATION_UIDS)}",
|
|
|
|
|
name="notifications/open",
|
|
|
|
|
)
|
2026-05-11 03:14:43 +02:00
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def mark_all_notifications_read(self):
|
2026-06-09 18:48:08 +02:00
|
|
|
self.client.post(
|
|
|
|
|
"/notifications/mark-all-read", name="notifications/mark-all-read"
|
|
|
|
|
)
|
2026-05-11 03:14:43 +02:00
|
|
|
|
2026-05-23 05:57:05 +02:00
|
|
|
@task(1)
|
|
|
|
|
def mark_one_notification_read(self):
|
|
|
|
|
if not NOTIFICATION_UIDS:
|
|
|
|
|
return
|
|
|
|
|
self.client.post(
|
|
|
|
|
f"/notifications/mark-read/{random.choice(NOTIFICATION_UIDS)}",
|
|
|
|
|
name="notifications/mark-read",
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-11 03:14:43 +02:00
|
|
|
@task(1)
|
|
|
|
|
def delete_comment(self):
|
|
|
|
|
if not COMMENT_UIDS:
|
|
|
|
|
return
|
|
|
|
|
self.client.post(
|
|
|
|
|
f"/comments/delete/{random.choice(COMMENT_UIDS)}",
|
|
|
|
|
name="comments/delete",
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-23 05:57:05 +02:00
|
|
|
# ── owner-scoped mutations ──────────────────────────────────
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def edit_post(self):
|
|
|
|
|
if not self.own_post_slugs:
|
|
|
|
|
return
|
|
|
|
|
self.client.post(
|
|
|
|
|
f"/posts/edit/{random.choice(self.own_post_slugs)}",
|
|
|
|
|
data={
|
|
|
|
|
"content": f"Edited {uuid.uuid4().hex} " * 3,
|
|
|
|
|
"title": "Edited post",
|
|
|
|
|
"topic": random.choice(TOPICS),
|
|
|
|
|
},
|
|
|
|
|
name="posts/edit",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def delete_post(self):
|
|
|
|
|
if not self.own_post_slugs:
|
|
|
|
|
return
|
|
|
|
|
slug = self.own_post_slugs.pop()
|
|
|
|
|
if slug in POST_SLUGS:
|
|
|
|
|
POST_SLUGS.remove(slug)
|
|
|
|
|
self.client.post(f"/posts/delete/{slug}", name="posts/delete")
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def edit_gist(self):
|
|
|
|
|
if not self.own_gist_slugs:
|
|
|
|
|
return
|
|
|
|
|
self.client.post(
|
|
|
|
|
f"/gists/edit/{random.choice(self.own_gist_slugs)}",
|
|
|
|
|
data={
|
|
|
|
|
"title": "Edited gist",
|
|
|
|
|
"description": "Edited description.",
|
|
|
|
|
"source_code": "y = 2\n" * 20,
|
|
|
|
|
"language": random.choice(GIST_LANGUAGES),
|
|
|
|
|
},
|
|
|
|
|
name="gists/edit",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def delete_gist(self):
|
|
|
|
|
if not self.own_gist_slugs:
|
|
|
|
|
return
|
|
|
|
|
slug = self.own_gist_slugs.pop()
|
|
|
|
|
if slug in GIST_SLUGS:
|
|
|
|
|
GIST_SLUGS.remove(slug)
|
|
|
|
|
self.client.post(f"/gists/delete/{slug}", name="gists/delete")
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def delete_project(self):
|
|
|
|
|
if not self.own_project_slugs:
|
|
|
|
|
return
|
|
|
|
|
slug = self.own_project_slugs.pop()
|
|
|
|
|
if slug in PROJECT_SLUGS:
|
|
|
|
|
PROJECT_SLUGS.remove(slug)
|
|
|
|
|
self.client.post(f"/projects/delete/{slug}", name="projects/delete")
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def upload_file(self):
|
|
|
|
|
name, content, mime = UPLOAD_FILE
|
|
|
|
|
with self.client.post(
|
|
|
|
|
"/uploads/upload",
|
|
|
|
|
files={"file": (name, content, mime)},
|
2026-06-09 18:48:08 +02:00
|
|
|
catch_response=True,
|
|
|
|
|
name="uploads/upload",
|
2026-05-23 05:57:05 +02:00
|
|
|
) as resp:
|
|
|
|
|
if resp.status_code == 201:
|
|
|
|
|
resp.success()
|
|
|
|
|
try:
|
|
|
|
|
uid = resp.json().get("uid")
|
2026-06-14 02:18:06 +02:00
|
|
|
except (ValueError, RequestException, AttributeError):
|
2026-05-23 05:57:05 +02:00
|
|
|
uid = None
|
|
|
|
|
else:
|
|
|
|
|
resp.failure(f"upload failed: status={resp.status_code}")
|
2026-06-14 02:18:06 +02:00
|
|
|
uid = None
|
|
|
|
|
if uid:
|
|
|
|
|
if len(self.own_media_uids) < 5 and random.random() < 0.5:
|
|
|
|
|
self.own_media_uids.append(uid)
|
|
|
|
|
else:
|
|
|
|
|
self.client.delete(f"/uploads/delete/{uid}", name="uploads/delete")
|
2026-05-23 05:57:05 +02:00
|
|
|
|
2026-06-02 23:17:51 +02:00
|
|
|
@task(1)
|
|
|
|
|
def register_push(self):
|
|
|
|
|
token = uuid.uuid4().hex
|
|
|
|
|
body = {
|
|
|
|
|
"endpoint": f"https://push.locust/endpoint/{token}",
|
|
|
|
|
"keys": {
|
|
|
|
|
"auth": uuid.uuid4().hex[:22],
|
|
|
|
|
"p256dh": (uuid.uuid4().hex + uuid.uuid4().hex)[:43],
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
with self.client.post(
|
2026-06-09 18:48:08 +02:00
|
|
|
"/push.json",
|
|
|
|
|
json=body,
|
|
|
|
|
catch_response=True,
|
|
|
|
|
name="push.json/register",
|
2026-06-02 23:17:51 +02:00
|
|
|
) as resp:
|
|
|
|
|
if resp.status_code in (200, 400):
|
|
|
|
|
resp.success()
|
|
|
|
|
else:
|
|
|
|
|
resp.failure(f"push register: status={resp.status_code}")
|
|
|
|
|
|
2026-06-14 01:31:28 +02:00
|
|
|
@task(1)
|
|
|
|
|
def regenerate_api_key(self):
|
|
|
|
|
self.client.post(
|
|
|
|
|
"/profile/regenerate-api-key", name="profile/regenerate-api-key"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def toggle_customization(self):
|
|
|
|
|
scope = random.choice(["global", "pagetype"])
|
|
|
|
|
self.client.post(
|
|
|
|
|
f"/profile/{self.username}/customization/{scope}",
|
|
|
|
|
data={"value": random.choice(["true", "false"])},
|
|
|
|
|
name=f"profile/customization/{scope}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def set_notification_preference(self):
|
|
|
|
|
self.client.post(
|
|
|
|
|
f"/profile/{self.username}/notifications",
|
|
|
|
|
data={
|
|
|
|
|
"notification_type": random.choice(NOTIFICATION_TYPES),
|
|
|
|
|
"channel": random.choice(["in_app", "push"]),
|
|
|
|
|
"value": random.choice(["true", "false"]),
|
|
|
|
|
},
|
|
|
|
|
name="profile/notifications",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def reset_notification_preferences(self):
|
|
|
|
|
self.client.post(
|
|
|
|
|
f"/profile/{self.username}/notifications/reset",
|
|
|
|
|
name="profile/notifications/reset",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def upload_url_attachment(self):
|
|
|
|
|
with self.client.post(
|
|
|
|
|
"/uploads/upload-url",
|
|
|
|
|
data={"url": "https://placehold.co/64x64.png"},
|
|
|
|
|
catch_response=True,
|
|
|
|
|
name="uploads/upload-url",
|
|
|
|
|
) as resp:
|
|
|
|
|
if resp.status_code in (201, 400, 422):
|
|
|
|
|
resp.success()
|
|
|
|
|
else:
|
|
|
|
|
resp.failure(f"upload-url: status={resp.status_code}")
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def manage_own_media(self):
|
|
|
|
|
if not self.own_media_uids:
|
|
|
|
|
return
|
|
|
|
|
uid = random.choice(self.own_media_uids)
|
|
|
|
|
self.client.post(f"/media/{uid}/delete", name="media/delete")
|
|
|
|
|
self.client.post(f"/media/{uid}/restore", name="media/restore")
|
|
|
|
|
|
|
|
|
|
@task(1)
|
2026-06-14 09:48:10 +02:00
|
|
|
def file_issue_job_status(self):
|
|
|
|
|
if not ISSUE_JOB_UIDS:
|
2026-06-14 01:31:28 +02:00
|
|
|
return
|
|
|
|
|
with self.client.get(
|
2026-06-14 09:48:10 +02:00
|
|
|
f"/issues/jobs/{random.choice(ISSUE_JOB_UIDS)}",
|
2026-06-14 01:31:28 +02:00
|
|
|
catch_response=True,
|
2026-06-14 09:48:10 +02:00
|
|
|
name="issues/jobs/[uid]",
|
2026-06-14 01:31:28 +02:00
|
|
|
) as resp:
|
|
|
|
|
if resp.status_code in (200, 404):
|
|
|
|
|
resp.success()
|
|
|
|
|
else:
|
2026-06-14 09:48:10 +02:00
|
|
|
resp.failure(f"issue job status: status={resp.status_code}")
|
2026-06-14 01:31:28 +02:00
|
|
|
|
|
|
|
|
# ── owner-scoped project mutations ──────────────────────────
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def edit_project(self):
|
|
|
|
|
if not self.own_project_slugs:
|
|
|
|
|
return
|
|
|
|
|
self.client.post(
|
|
|
|
|
f"/projects/edit/{random.choice(self.own_project_slugs)}",
|
|
|
|
|
data={
|
|
|
|
|
"title": "Edited project",
|
|
|
|
|
"description": "Edited description for load test.",
|
|
|
|
|
"project_type": random.choice(PROJECT_TYPES),
|
|
|
|
|
},
|
|
|
|
|
name="projects/edit",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def toggle_project_flags(self):
|
|
|
|
|
if not self.own_project_slugs:
|
|
|
|
|
return
|
|
|
|
|
slug = random.choice(self.own_project_slugs)
|
|
|
|
|
self.client.post(
|
|
|
|
|
f"/projects/{slug}/{random.choice(['private', 'readonly'])}",
|
|
|
|
|
name="projects/[slug]/flag",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@task(2)
|
|
|
|
|
def browse_project_files(self):
|
|
|
|
|
if not PROJECT_SLUGS:
|
|
|
|
|
return
|
|
|
|
|
slug = random.choice(PROJECT_SLUGS)
|
|
|
|
|
self.client.get(
|
|
|
|
|
f"/projects/{slug}/files", params={"path": ""}, name="projects/[slug]/files"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@task(2)
|
|
|
|
|
def edit_own_project_files(self):
|
|
|
|
|
if not self.own_project_slugs:
|
|
|
|
|
return
|
|
|
|
|
slug = random.choice(self.own_project_slugs)
|
|
|
|
|
path = f"notes/{uuid.uuid4().hex[:6]}.txt"
|
|
|
|
|
with self.client.post(
|
|
|
|
|
f"/projects/{slug}/files/write",
|
|
|
|
|
data={"path": path, "content": "line one\nline two\nline three\n"},
|
|
|
|
|
catch_response=True,
|
|
|
|
|
name="projects/[slug]/files/write",
|
|
|
|
|
) as resp:
|
|
|
|
|
if resp.status_code in (200, 302, 303):
|
|
|
|
|
resp.success()
|
|
|
|
|
else:
|
|
|
|
|
resp.failure(f"file write: status={resp.status_code}")
|
|
|
|
|
return
|
|
|
|
|
self.client.get(
|
|
|
|
|
f"/projects/{slug}/files/raw",
|
|
|
|
|
params={"path": path},
|
|
|
|
|
name="projects/[slug]/files/raw",
|
|
|
|
|
)
|
|
|
|
|
self.client.get(
|
|
|
|
|
f"/projects/{slug}/files/lines",
|
|
|
|
|
params={"path": path, "start": 1, "end": 2},
|
|
|
|
|
name="projects/[slug]/files/lines",
|
|
|
|
|
)
|
|
|
|
|
self.client.post(
|
|
|
|
|
f"/projects/{slug}/files/append",
|
|
|
|
|
data={"path": path, "content": "appended line\n"},
|
|
|
|
|
name="projects/[slug]/files/append",
|
|
|
|
|
)
|
|
|
|
|
self.client.post(
|
|
|
|
|
f"/projects/{slug}/files/replace-lines",
|
|
|
|
|
data={"path": path, "start": 1, "end": 1, "content": "replaced line"},
|
|
|
|
|
name="projects/[slug]/files/replace-lines",
|
|
|
|
|
)
|
|
|
|
|
self.client.post(
|
|
|
|
|
f"/projects/{slug}/files/insert-lines",
|
|
|
|
|
data={"path": path, "at": 1, "content": "inserted line"},
|
|
|
|
|
name="projects/[slug]/files/insert-lines",
|
|
|
|
|
)
|
|
|
|
|
self.client.post(
|
|
|
|
|
f"/projects/{slug}/files/delete-lines",
|
|
|
|
|
data={"path": path, "start": 1, "end": 1},
|
|
|
|
|
name="projects/[slug]/files/delete-lines",
|
|
|
|
|
)
|
|
|
|
|
moved = f"notes/{uuid.uuid4().hex[:6]}.txt"
|
|
|
|
|
self.client.post(
|
|
|
|
|
f"/projects/{slug}/files/move",
|
|
|
|
|
data={"from_path": path, "to_path": moved},
|
|
|
|
|
name="projects/[slug]/files/move",
|
|
|
|
|
)
|
|
|
|
|
self.client.post(
|
|
|
|
|
f"/projects/{slug}/files/delete",
|
|
|
|
|
data={"path": moved},
|
|
|
|
|
name="projects/[slug]/files/delete",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def make_project_dir_and_upload(self):
|
|
|
|
|
if not self.own_project_slugs:
|
|
|
|
|
return
|
|
|
|
|
slug = random.choice(self.own_project_slugs)
|
|
|
|
|
directory = f"dir_{uuid.uuid4().hex[:6]}"
|
|
|
|
|
self.client.post(
|
|
|
|
|
f"/projects/{slug}/files/mkdir",
|
|
|
|
|
data={"path": directory},
|
|
|
|
|
name="projects/[slug]/files/mkdir",
|
|
|
|
|
)
|
|
|
|
|
name, content, mime = UPLOAD_FILE
|
|
|
|
|
self.client.post(
|
|
|
|
|
f"/projects/{slug}/files/upload",
|
|
|
|
|
data={"path": directory},
|
|
|
|
|
files={"file": (name, content, mime)},
|
|
|
|
|
name="projects/[slug]/files/upload",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def zip_project(self):
|
|
|
|
|
if not self.own_project_slugs:
|
|
|
|
|
return
|
|
|
|
|
slug = random.choice(self.own_project_slugs)
|
|
|
|
|
endpoint = random.choice(["zip", "files/zip"])
|
|
|
|
|
with self.client.post(
|
|
|
|
|
f"/projects/{slug}/{endpoint}",
|
|
|
|
|
params={"path": ""},
|
|
|
|
|
catch_response=True,
|
|
|
|
|
name="projects/[slug]/zip",
|
|
|
|
|
) as resp:
|
|
|
|
|
if resp.status_code not in (200, 202):
|
|
|
|
|
resp.failure(f"zip enqueue: status={resp.status_code}")
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
uid = resp.json().get("uid")
|
2026-06-14 02:18:06 +02:00
|
|
|
except (ValueError, RequestException, AttributeError):
|
|
|
|
|
match = re.search(rf"/zips/({UUID_RE})", resp.text or "")
|
|
|
|
|
uid = match.group(1) if match else None
|
2026-06-14 01:31:28 +02:00
|
|
|
resp.success()
|
2026-06-14 02:18:06 +02:00
|
|
|
if uid:
|
|
|
|
|
self.client.get(f"/zips/{uid}", name="zips/[uid]")
|
|
|
|
|
with self.client.get(
|
|
|
|
|
f"/zips/{uid}/download",
|
|
|
|
|
name="zips/[uid]/download",
|
|
|
|
|
catch_response=True,
|
|
|
|
|
) as download:
|
|
|
|
|
if download.status_code in (200, 202, 404):
|
|
|
|
|
download.success()
|
|
|
|
|
else:
|
|
|
|
|
download.failure(f"zip download: status={download.status_code}")
|
2026-06-14 01:31:28 +02:00
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def fork_project(self):
|
|
|
|
|
if not PROJECT_SLUGS:
|
|
|
|
|
return
|
|
|
|
|
slug = random.choice(PROJECT_SLUGS)
|
|
|
|
|
with self.client.post(
|
|
|
|
|
f"/projects/{slug}/fork",
|
|
|
|
|
data={"title": f"Fork {uuid.uuid4().hex[:6]}"},
|
|
|
|
|
catch_response=True,
|
|
|
|
|
name="projects/[slug]/fork",
|
|
|
|
|
) as resp:
|
|
|
|
|
if resp.status_code not in (200, 202):
|
|
|
|
|
resp.failure(f"fork enqueue: status={resp.status_code}")
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
uid = resp.json().get("uid")
|
2026-06-14 02:18:06 +02:00
|
|
|
except (ValueError, RequestException, AttributeError):
|
2026-06-14 01:31:28 +02:00
|
|
|
uid = None
|
|
|
|
|
resp.success()
|
2026-06-14 02:18:06 +02:00
|
|
|
if uid:
|
|
|
|
|
self.client.get(f"/forks/{uid}", name="forks/[uid]")
|
2026-06-14 01:31:28 +02:00
|
|
|
|
2026-05-11 03:14:43 +02:00
|
|
|
# ── static / media ──────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
@task(2)
|
2026-05-12 12:45:52 +02:00
|
|
|
def view_multiavatar(self):
|
2026-05-11 03:14:43 +02:00
|
|
|
seed = random.choice(ALL_USERNAMES) if ALL_USERNAMES else "anon"
|
2026-06-02 23:17:51 +02:00
|
|
|
resp = self.client.get(
|
2026-05-12 12:45:52 +02:00
|
|
|
f"/avatar/multiavatar/{seed}?size=32", name="avatar/multiavatar"
|
|
|
|
|
)
|
2026-06-02 23:17:51 +02:00
|
|
|
etag = resp.headers.get("ETag")
|
|
|
|
|
if not etag:
|
|
|
|
|
return
|
|
|
|
|
with self.client.get(
|
|
|
|
|
f"/avatar/multiavatar/{seed}?size=32",
|
|
|
|
|
headers={"If-None-Match": etag},
|
2026-06-09 18:48:08 +02:00
|
|
|
catch_response=True,
|
|
|
|
|
name="avatar/multiavatar/304",
|
2026-06-02 23:17:51 +02:00
|
|
|
) as cached:
|
|
|
|
|
if cached.status_code == 304:
|
|
|
|
|
cached.success()
|
|
|
|
|
else:
|
|
|
|
|
cached.failure(f"expected 304, got {cached.status_code}")
|
2026-05-12 12:45:52 +02:00
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def comment_on_news(self):
|
|
|
|
|
if not NEWS_UIDS:
|
|
|
|
|
return
|
2026-06-09 18:48:08 +02:00
|
|
|
self.client.post(
|
|
|
|
|
"/comments/create",
|
|
|
|
|
data={
|
|
|
|
|
"content": f"News comment {uuid.uuid4().hex[:6]} " * 2,
|
|
|
|
|
"target_uid": random.choice(NEWS_UIDS),
|
|
|
|
|
"target_type": "news",
|
|
|
|
|
},
|
|
|
|
|
name="comments/news",
|
|
|
|
|
)
|
2026-05-23 05:57:05 +02:00
|
|
|
|
2026-06-02 23:17:51 +02:00
|
|
|
@task(1)
|
|
|
|
|
def comment_on_target(self):
|
|
|
|
|
pools = []
|
|
|
|
|
if PROJECT_UIDS:
|
|
|
|
|
pools.append(("project", PROJECT_UIDS))
|
|
|
|
|
if GIST_UIDS:
|
|
|
|
|
pools.append(("gist", GIST_UIDS))
|
2026-06-14 09:48:10 +02:00
|
|
|
if ISSUE_UIDS:
|
|
|
|
|
pools.append(("issue", ISSUE_UIDS))
|
2026-06-02 23:17:51 +02:00
|
|
|
if not pools:
|
|
|
|
|
return
|
|
|
|
|
target_type, uids = random.choice(pools)
|
2026-06-09 18:48:08 +02:00
|
|
|
self.client.post(
|
|
|
|
|
"/comments/create",
|
|
|
|
|
data={
|
|
|
|
|
"content": f"{target_type} comment {uuid.uuid4().hex[:6]} " * 2,
|
|
|
|
|
"target_uid": random.choice(uids),
|
|
|
|
|
"target_type": target_type,
|
|
|
|
|
},
|
|
|
|
|
name=f"comments/{target_type}",
|
|
|
|
|
)
|
2026-06-02 23:17:51 +02:00
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def reply_to_comment(self):
|
|
|
|
|
if not POST_UIDS or not COMMENT_UIDS:
|
|
|
|
|
return
|
2026-06-09 18:48:08 +02:00
|
|
|
self.client.post(
|
|
|
|
|
"/comments/create",
|
|
|
|
|
data={
|
|
|
|
|
"content": f"Reply {uuid.uuid4().hex[:6]} " * 2,
|
|
|
|
|
"post_uid": random.choice(POST_UIDS),
|
|
|
|
|
"parent_uid": random.choice(COMMENT_UIDS),
|
|
|
|
|
},
|
|
|
|
|
name="comments/reply",
|
|
|
|
|
)
|
2026-06-02 23:17:51 +02:00
|
|
|
|
|
|
|
|
@task(2)
|
|
|
|
|
def browse_and_engage(self):
|
|
|
|
|
slugs = POST_SLUGS if POST_SLUGS else POST_UIDS
|
|
|
|
|
if not slugs:
|
|
|
|
|
return
|
2026-06-09 18:48:08 +02:00
|
|
|
resp = self.client.get(f"/posts/{random.choice(slugs)}", name="posts/[uid]")
|
|
|
|
|
vote_uid = re.search(rf"/votes/post/({UUID_RE})", resp.text)
|
2026-06-02 23:17:51 +02:00
|
|
|
if vote_uid:
|
|
|
|
|
self._vote("post", vote_uid.group(1))
|
2026-06-09 18:48:08 +02:00
|
|
|
comment_uid = re.search(rf"/comments/delete/({UUID_RE})", resp.text)
|
2026-06-02 23:17:51 +02:00
|
|
|
if comment_uid and comment_uid.group(1) not in COMMENT_UIDS:
|
|
|
|
|
COMMENT_UIDS.append(comment_uid.group(1))
|
|
|
|
|
|
2026-05-23 05:57:05 +02:00
|
|
|
|
|
|
|
|
class AdminUser(HttpUser):
|
|
|
|
|
weight = 1
|
|
|
|
|
wait_time = between(2, 6)
|
|
|
|
|
|
|
|
|
|
def on_start(self):
|
2026-06-14 02:18:06 +02:00
|
|
|
assign_client_ip(self.client)
|
2026-05-23 05:57:05 +02:00
|
|
|
if ADMIN_USER:
|
|
|
|
|
do_login(self.client, ADMIN_USER["email"])
|
|
|
|
|
|
|
|
|
|
@task(2)
|
|
|
|
|
def view_admin_index(self):
|
|
|
|
|
self.client.get("/admin", name="admin")
|
|
|
|
|
|
|
|
|
|
@task(3)
|
|
|
|
|
def view_admin_users(self):
|
|
|
|
|
self.client.get("/admin/users", name="admin/users")
|
|
|
|
|
|
|
|
|
|
@task(2)
|
|
|
|
|
def view_admin_settings(self):
|
|
|
|
|
self.client.get("/admin/settings", name="admin/settings")
|
|
|
|
|
|
|
|
|
|
@task(2)
|
|
|
|
|
def view_admin_news(self):
|
|
|
|
|
self.client.get("/admin/news", name="admin/news")
|
|
|
|
|
|
|
|
|
|
@task(2)
|
|
|
|
|
def view_admin_services(self):
|
|
|
|
|
self.client.get("/admin/services", name="admin/services")
|
|
|
|
|
|
|
|
|
|
@task(2)
|
|
|
|
|
def view_admin_services_data(self):
|
|
|
|
|
self.client.get("/admin/services/data", name="admin/services/data")
|
|
|
|
|
|
2026-06-14 01:31:28 +02:00
|
|
|
@task(1)
|
|
|
|
|
def view_admin_service_detail(self):
|
|
|
|
|
self.client.get("/admin/services/audit", name="admin/services/[name]")
|
|
|
|
|
self.client.get(
|
|
|
|
|
"/admin/services/audit/data", name="admin/services/[name]/data"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@task(2)
|
|
|
|
|
def view_admin_ai_usage(self):
|
|
|
|
|
self.client.get("/admin/ai-usage", name="admin/ai-usage")
|
|
|
|
|
self.client.get("/admin/ai-usage/data", name="admin/ai-usage/data")
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def view_admin_analytics(self):
|
|
|
|
|
self.client.get("/admin/analytics", name="admin/analytics")
|
|
|
|
|
|
|
|
|
|
@task(2)
|
|
|
|
|
def view_admin_audit_log(self):
|
|
|
|
|
resp = self.client.get("/admin/audit-log", name="admin/audit-log")
|
|
|
|
|
m = re.search(rf"/admin/audit-log/({UUID_RE})", resp.text)
|
|
|
|
|
if m:
|
|
|
|
|
self.client.get(
|
|
|
|
|
f"/admin/audit-log/{m.group(1)}", name="admin/audit-log/[uid]"
|
|
|
|
|
)
|
|
|
|
|
|
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
|
|
|
@task(3)
|
|
|
|
|
def view_moderation_queue(self):
|
|
|
|
|
resp = self.client.get(
|
|
|
|
|
"/admin/moderation",
|
|
|
|
|
params={"status": random.choice(REPORT_STATUSES)},
|
|
|
|
|
name="admin/moderation",
|
|
|
|
|
)
|
|
|
|
|
m = re.search(rf"/admin/moderation/({UUID_RE})", resp.text)
|
|
|
|
|
uid = m.group(1) if m else ADMIN_TARGETS.get("report_uid")
|
|
|
|
|
if not uid:
|
|
|
|
|
return
|
|
|
|
|
with self.client.get(
|
|
|
|
|
f"/admin/moderation/{uid}",
|
|
|
|
|
catch_response=True,
|
|
|
|
|
name="admin/moderation/[uid]",
|
|
|
|
|
) as report:
|
|
|
|
|
if report.status_code == 200:
|
|
|
|
|
report.success()
|
|
|
|
|
else:
|
|
|
|
|
report.failure(f"report detail: status={report.status_code}")
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def view_admin_docs(self):
|
|
|
|
|
self.client.get(
|
|
|
|
|
f"/docs/{random.choice(ADMIN_DOCS_SLUGS)}.html", name="docs/[slug]"
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-14 01:31:28 +02:00
|
|
|
@task(1)
|
|
|
|
|
def view_admin_media(self):
|
|
|
|
|
self.client.get("/admin/media", name="admin/media")
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def view_admin_notifications(self):
|
|
|
|
|
self.client.get("/admin/notifications", name="admin/notifications")
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def save_admin_notification_default(self):
|
|
|
|
|
self.client.post(
|
|
|
|
|
"/admin/notifications",
|
|
|
|
|
data={
|
|
|
|
|
"notification_type": random.choice(NOTIFICATION_TYPES),
|
|
|
|
|
"channel": random.choice(["in_app", "push"]),
|
|
|
|
|
"value": random.choice(["true", "false"]),
|
|
|
|
|
},
|
|
|
|
|
name="admin/notifications/save",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def view_admin_user_ai_usage(self):
|
|
|
|
|
uid = ADMIN_TARGETS.get("disposable_uid")
|
|
|
|
|
if not uid:
|
|
|
|
|
return
|
|
|
|
|
self.client.get(
|
|
|
|
|
f"/admin/users/{uid}/ai-usage", name="admin/users/[uid]/ai-usage"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def reset_user_ai_quota(self):
|
|
|
|
|
uid = ADMIN_TARGETS.get("disposable_uid")
|
|
|
|
|
if not uid:
|
|
|
|
|
return
|
|
|
|
|
self.client.post(
|
|
|
|
|
f"/admin/users/{uid}/reset-ai-quota", name="admin/users/reset-ai-quota"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def trash_view_and_restore(self):
|
|
|
|
|
resp = self.client.get("/admin/trash", name="admin/trash")
|
|
|
|
|
m = re.search(
|
|
|
|
|
rf"/admin/trash/([a-z_]+)/({UUID_RE})/restore", resp.text
|
|
|
|
|
)
|
|
|
|
|
if m:
|
|
|
|
|
self.client.post(
|
|
|
|
|
f"/admin/trash/{m.group(1)}/{m.group(2)}/restore",
|
|
|
|
|
name="admin/trash/restore",
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-23 05:57:05 +02:00
|
|
|
@task(1)
|
|
|
|
|
def save_settings(self):
|
2026-06-09 18:48:08 +02:00
|
|
|
self.client.post(
|
|
|
|
|
"/admin/settings",
|
|
|
|
|
data={
|
|
|
|
|
"max_upload_size_mb": "10",
|
|
|
|
|
},
|
|
|
|
|
name="admin/settings/save",
|
|
|
|
|
)
|
2026-05-23 05:57:05 +02:00
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def news_toggle(self):
|
|
|
|
|
uid = ADMIN_TARGETS.get("news_uid")
|
|
|
|
|
if not uid:
|
|
|
|
|
return
|
|
|
|
|
self.client.post(f"/admin/news/{uid}/toggle", name="admin/news/toggle")
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def news_publish(self):
|
|
|
|
|
uid = ADMIN_TARGETS.get("news_uid")
|
|
|
|
|
if not uid:
|
|
|
|
|
return
|
|
|
|
|
self.client.post(f"/admin/news/{uid}/publish", name="admin/news/publish")
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def news_landing(self):
|
|
|
|
|
uid = ADMIN_TARGETS.get("news_uid")
|
|
|
|
|
if not uid:
|
|
|
|
|
return
|
|
|
|
|
self.client.post(f"/admin/news/{uid}/landing", name="admin/news/landing")
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def news_delete(self):
|
|
|
|
|
uid = ADMIN_TARGETS.get("news_uid")
|
|
|
|
|
if not uid:
|
|
|
|
|
return
|
|
|
|
|
self.client.post(f"/admin/news/{uid}/delete", name="admin/news/delete")
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def user_role(self):
|
|
|
|
|
uid = ADMIN_TARGETS.get("disposable_uid")
|
|
|
|
|
if not uid:
|
|
|
|
|
return
|
|
|
|
|
self.client.post(
|
2026-06-09 18:48:08 +02:00
|
|
|
f"/admin/users/{uid}/role",
|
|
|
|
|
data={"role": "Member"},
|
2026-05-23 05:57:05 +02:00
|
|
|
name="admin/users/role",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def user_password(self):
|
|
|
|
|
uid = ADMIN_TARGETS.get("disposable_uid")
|
|
|
|
|
if not uid:
|
|
|
|
|
return
|
|
|
|
|
self.client.post(
|
2026-06-09 18:48:08 +02:00
|
|
|
f"/admin/users/{uid}/password",
|
|
|
|
|
data={"password": PASSWORD},
|
2026-05-23 05:57:05 +02:00
|
|
|
name="admin/users/password",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def user_toggle(self):
|
|
|
|
|
uid = ADMIN_TARGETS.get("disposable_uid")
|
|
|
|
|
if not uid:
|
|
|
|
|
return
|
2026-06-09 18:48:08 +02:00
|
|
|
self.client.post(f"/admin/users/{uid}/toggle", name="admin/users/toggle")
|
2026-06-05 19:32:46 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class AnonymousUser(HttpUser):
|
|
|
|
|
weight = 3
|
|
|
|
|
wait_time = between(1, 5)
|
|
|
|
|
|
2026-06-14 02:18:06 +02:00
|
|
|
def on_start(self):
|
|
|
|
|
assign_client_ip(self.client)
|
|
|
|
|
|
2026-06-05 19:32:46 +02:00
|
|
|
@task(5)
|
|
|
|
|
def browse_feed(self):
|
|
|
|
|
self.client.get("/feed", name="feed")
|
|
|
|
|
|
|
|
|
|
@task(3)
|
|
|
|
|
def browse_post(self):
|
|
|
|
|
if POST_SLUGS:
|
2026-06-09 18:48:08 +02:00
|
|
|
self.client.get(f"/posts/{random.choice(POST_SLUGS)}", name="posts/[uid]")
|
2026-06-05 19:32:46 +02:00
|
|
|
|
|
|
|
|
@task(2)
|
|
|
|
|
def browse_public(self):
|
2026-06-09 18:48:08 +02:00
|
|
|
path = random.choice(
|
|
|
|
|
[
|
|
|
|
|
"/",
|
|
|
|
|
"/news",
|
|
|
|
|
"/gists",
|
|
|
|
|
"/projects",
|
|
|
|
|
"/leaderboard",
|
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
|
|
|
"/reports/reasons",
|
|
|
|
|
"/workspaces/index",
|
2026-06-09 18:48:08 +02:00
|
|
|
]
|
|
|
|
|
)
|
2026-06-05 19:32:46 +02:00
|
|
|
self.client.get(path, name="public/[page]")
|
|
|
|
|
|
2026-06-14 01:31:28 +02:00
|
|
|
@task(1)
|
|
|
|
|
def browse_docs(self):
|
|
|
|
|
self.client.get(
|
|
|
|
|
f"/docs/{random.choice(DOCS_SLUGS)}.html", name="docs/[slug]"
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-05 19:32:46 +02:00
|
|
|
@task(2)
|
|
|
|
|
def browse_detail(self):
|
|
|
|
|
if NEWS_SLUGS:
|
2026-06-09 18:48:08 +02:00
|
|
|
self.client.get(f"/news/{random.choice(NEWS_SLUGS)}", name="news/[slug]")
|
2026-06-05 19:32:46 +02:00
|
|
|
if GIST_SLUGS:
|
2026-06-09 18:48:08 +02:00
|
|
|
self.client.get(f"/gists/{random.choice(GIST_SLUGS)}", name="gists/[slug]")
|
2026-06-05 19:32:46 +02:00
|
|
|
if PROJECT_SLUGS:
|
|
|
|
|
self.client.get(
|
|
|
|
|
f"/projects/{random.choice(PROJECT_SLUGS)}", name="projects/[slug]"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@task(2)
|
|
|
|
|
def guest_vote_redirects_to_login(self):
|
|
|
|
|
if not POST_UIDS:
|
|
|
|
|
return
|
|
|
|
|
uid = random.choice(POST_UIDS)
|
|
|
|
|
with self.client.post(
|
2026-06-09 18:48:08 +02:00
|
|
|
f"/votes/post/{uid}",
|
|
|
|
|
data={"value": 1},
|
2026-06-05 19:32:46 +02:00
|
|
|
headers={"x-requested-with": "fetch"},
|
2026-06-09 18:48:08 +02:00
|
|
|
catch_response=True,
|
|
|
|
|
name="votes/post/guest-redirect",
|
2026-06-05 19:32:46 +02:00
|
|
|
) as resp:
|
2026-06-14 16:46:36 +02:00
|
|
|
if "/auth/login" in (resp.url or ""):
|
2026-06-05 19:32:46 +02:00
|
|
|
resp.success()
|
|
|
|
|
else:
|
|
|
|
|
resp.failure(f"guest vote not redirected to login: {resp.url}")
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def guest_guarded_get_redirects_to_login(self):
|
|
|
|
|
with self.client.get(
|
2026-06-09 18:48:08 +02:00
|
|
|
"/messages",
|
|
|
|
|
catch_response=True,
|
|
|
|
|
name="guarded/guest-redirect",
|
2026-06-05 19:32:46 +02:00
|
|
|
) as resp:
|
2026-06-14 16:46:36 +02:00
|
|
|
if "/auth/login" in (resp.url or ""):
|
2026-06-05 19:32:46 +02:00
|
|
|
resp.success()
|
|
|
|
|
else:
|
|
|
|
|
resp.failure(f"guarded GET not redirected to login: {resp.url}")
|
|
|
|
|
|
|
|
|
|
@task(1)
|
|
|
|
|
def login_with_next(self):
|
|
|
|
|
self.client.get("/auth/login", params={"next": "/feed"}, name="auth/login?next")
|