From 81d6aa68b65ee8901e0e73d9a7ad547d36e12f23 Mon Sep 17 00:00:00 2001 From: retoor Date: Sun, 30 Aug 2026 01:13:43 +0200 Subject: [PATCH] Optimization --- devplacepy/attachments.py | 22 + devplacepy/routers/issues/attachments.py | 12 +- devplacepy/routers/issues/comment.py | 4 +- locustfile.py | 927 ++++++++++++++++++++++- tests/api/issues/planning.py | 85 +++ tests/e2e/admin/deviitasks.py | 95 +++ tests/e2e/admin/trash.py | 66 ++ tests/e2e/profile/search.py | 58 ++ 8 files changed, 1234 insertions(+), 35 deletions(-) create mode 100644 tests/e2e/admin/deviitasks.py create mode 100644 tests/e2e/admin/trash.py diff --git a/devplacepy/attachments.py b/devplacepy/attachments.py index fe262da..c8016c8 100644 --- a/devplacepy/attachments.py +++ b/devplacepy/attachments.py @@ -545,6 +545,28 @@ async def remove_gitea_asset(row): logger.warning("Gitea asset delete failed for %s: %s", row.get("uid"), exc) +_pending_gitea_tasks: set[asyncio.Task] = set() + + +def _fire_and_forget(coro) -> None: + try: + loop = asyncio.get_running_loop() + except RuntimeError: + coro.close() + return + task = loop.create_task(coro) + _pending_gitea_tasks.add(task) + task.add_done_callback(_pending_gitea_tasks.discard) + + +def schedule_gitea_mirror(uid: str) -> None: + _fire_and_forget(mirror_attachment_to_gitea(uid)) + + +def schedule_gitea_removal(row: dict) -> None: + _fire_and_forget(remove_gitea_asset(row)) + + def _unlink_attachment_files(row): stored_name = row.get("stored_name", "") directory = row.get("directory", "") diff --git a/devplacepy/routers/issues/attachments.py b/devplacepy/routers/issues/attachments.py index 0630683..18ca5a6 100644 --- a/devplacepy/routers/issues/attachments.py +++ b/devplacepy/routers/issues/attachments.py @@ -10,8 +10,8 @@ from devplacepy.attachments import ( get_attachments, get_orphan_attachments_batch, link_attachments, - mirror_attachment_to_gitea, - remove_gitea_asset, + schedule_gitea_mirror, + schedule_gitea_removal, soft_delete_attachment, split_attachment_uids, ) @@ -96,7 +96,7 @@ async def add_issue_attachment( return json_error(400, "No valid attachments to add") link_attachments(owned, "issue", str(number)) for uid in owned: - await mirror_attachment_to_gitea(uid) + schedule_gitea_mirror(uid) audit.record( request, "issue.attachment.add", @@ -146,7 +146,7 @@ async def delete_issue_attachment(request: Request, number: int, uid: str): ) return json_error(409, "Attachments cannot be changed on a closed issue") soft_delete_attachment(uid, deleted_by=user["uid"]) - await remove_gitea_asset(att) + schedule_gitea_removal(att) audit.record( request, "issue.attachment.delete", @@ -194,7 +194,7 @@ async def add_comment_attachment( return json_error(400, "No valid attachments to add") link_attachments(owned, "issue_comment", str(cid)) for uid in owned: - await mirror_attachment_to_gitea(uid) + schedule_gitea_mirror(uid) audit.record( request, "issue.attachment.add", @@ -237,7 +237,7 @@ async def delete_comment_attachment( if issue.get("state") != STATE_OPEN: return json_error(409, "Attachments cannot be changed on a closed issue") soft_delete_attachment(uid, deleted_by=user["uid"]) - await remove_gitea_asset(att) + schedule_gitea_removal(att) audit.record( request, "issue.attachment.delete", diff --git a/devplacepy/routers/issues/comment.py b/devplacepy/routers/issues/comment.py index ab0fcb2..cc44b2a 100644 --- a/devplacepy/routers/issues/comment.py +++ b/devplacepy/routers/issues/comment.py @@ -8,7 +8,7 @@ from fastapi import Depends, APIRouter, Request from devplacepy.attachments import ( get_orphan_attachments_batch, link_attachments, - mirror_attachment_to_gitea, + schedule_gitea_mirror, split_attachment_uids, ) from devplacepy.database import get_table @@ -73,7 +73,7 @@ async def comment_issue( if owned: link_attachments(owned, "issue_comment", str(comment_id)) for uid in owned: - await mirror_attachment_to_gitea(uid) + schedule_gitea_mirror(uid) background.submit(_notify_admins, user, number) audit.record( request, diff --git a/locustfile.py b/locustfile.py index 09466c1..264d249 100644 --- a/locustfile.py +++ b/locustfile.py @@ -2,6 +2,7 @@ import logging import random import re import uuid +import xmlrpc.client from locust import HttpUser, task, between, events from requests.exceptions import RequestException @@ -25,9 +26,13 @@ GIST_SLUGS = [] GIST_UIDS = [] ISSUE_UIDS = [] ISSUE_JOB_UIDS = [] -NOTIFICATION_UIDS = [] SEO_JOB_UIDS = [] POLLS = [] +QUIZ_SLUGS = [] +QUIZ_QUESTIONS = {} +BATTLE_UIDS = [] +DR_RANT_IDS = [] +DR_COMMENT_IDS = [] ADMIN_USER = {} ADMIN_TARGETS = {} TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random", "politics"] @@ -99,7 +104,28 @@ REPORT_STATUSES = ["open", "acknowledged", "actioned", "dismissed"] # /profile/{username}/consent, /mature-content # - flips real consent state. # /auth/accept-terms - mutates real acceptance records. -# WebSockets (/devii/ws, exec ws) - HttpUser cannot drive them. +# WebSockets (/devii/ws, exec ws, /tools/seo/{uid}/ws, /tools/deepsearch/{uid}/ws, +# /tools/deepsearch/{uid}/chat, /pubsub/ws, /messages/ws) +# - HttpUser is HTTP-only. +# /game/prestige, /game/legacy - prestige is a full irreversible farm +# reset (coins/xp/level/ci/perks) gated +# behind a level most simulated users +# never reach; firing it under continuous +# load would repeatedly wipe a shared +# farm's progression and corrupt the +# load test's own state model for no +# realistic traffic benefit. Every other +# Code Farm mutation is covered. +# /admin/game/era/start|end - global, server-wide game-state reset +# (admin-triggered Era transition). +# /dbapi/* - primary-administrator-only (a single +# fixed identity, not "any admin"); no +# disposable multi-actor target exists. +# POST /profile/{username}/award - enqueues a real AI-generated award +# image job (billing-sensitive), same +# class as /tools/seo/run. +# DELETE /api/users/me - devRant-protocol account deletion, +# same class as /profile/{username}/delete. def random_username(): @@ -210,20 +236,26 @@ def seed_data(environment, **kwargs): opener.open(urllib.request.Request(f"{host}/auth/login", data=body), timeout=10) return opener - for seed in SEED_USERS[:3]: + for index, seed in enumerate(SEED_USERS[:3]): try: opener = logged_in_opener(seed["email"]) - body = urllib.parse.urlencode( - [ - ("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"), - ] - ).encode() + fields = [ + ("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"), + ] + if index == 0: + fields.extend( + [ + ("war_faction_a", "Tabs"), + ("war_faction_b", "Spaces"), + ] + ) + body = urllib.parse.urlencode(fields).encode() resp = opener.open( urllib.request.Request(f"{host}/posts/create", data=body), timeout=10, @@ -235,10 +267,13 @@ def seed_data(environment, **kwargs): m2 = re.search(rf"/votes/post/({UUID_RE})", html) if m2 and m2.group(1) not in POST_UIDS: POST_UIDS.append(m2.group(1)) + m3 = re.search(rf']*\buid="({UUID_RE})"', html) + if m3 and m3.group(1) not in BATTLE_UIDS: + BATTLE_UIDS.append(m3.group(1)) except Exception as e: logger.warning(f"Create seed post failed: {e}") - logger.info(f"Created {len(POST_UIDS)} seed posts") + logger.info(f"Created {len(POST_UIDS)} seed posts, {len(BATTLE_UIDS)} battles") for post_uid in POST_UIDS: for seed in SEED_USERS[:2]: @@ -545,6 +580,9 @@ class DevPlaceUser(HttpUser): self.own_gist_slugs = [] self.own_project_slugs = [] self.own_media_uids = [] + self.own_comment_uids = [] + self.notification_uids = [] + self.own_quiz_slugs = [] if random.random() < 0.3 and SEED_USERS: seed = random.choice(SEED_USERS) do_login(self.client, seed["email"]) @@ -838,14 +876,28 @@ class DevPlaceUser(HttpUser): def comment_on_post(self): if not POST_UIDS: return - self.client.post( + with self.client.post( "/comments/create", data={ "content": f"Comment {uuid.uuid4().hex[:6]} " * 3, "post_uid": random.choice(POST_UIDS), }, + headers={"Accept": "application/json"}, + catch_response=True, name="comments/create", - ) + ) as resp: + if resp.status_code != 200: + resp.failure(f"comment create: status={resp.status_code}") + return + try: + uid = (resp.json().get("data") or {}).get("uid") + except (ValueError, RequestException, AttributeError): + uid = None + if uid: + if uid not in COMMENT_UIDS: + COMMENT_UIDS.append(uid) + self.own_comment_uids.append(uid) + resp.success() @task(1) def create_project(self): @@ -1134,7 +1186,9 @@ class DevPlaceUser(HttpUser): def view_notifications(self): resp = self.client.get("/notifications", name="notifications") 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) + self.notification_uids.extend( + m for m in matches if m not in self.notification_uids + ) cursor = re.search(r'/notifications\?before=([^"]+)', resp.text) if cursor: self.client.get( @@ -1145,10 +1199,10 @@ class DevPlaceUser(HttpUser): @task(1) def open_notification(self): - if not NOTIFICATION_UIDS: + if not self.notification_uids: return self.client.get( - f"/notifications/open/{random.choice(NOTIFICATION_UIDS)}", + f"/notifications/open/{random.choice(self.notification_uids)}", name="notifications/open", ) @@ -1160,21 +1214,21 @@ class DevPlaceUser(HttpUser): @task(1) def mark_one_notification_read(self): - if not NOTIFICATION_UIDS: + if not self.notification_uids: return self.client.post( - f"/notifications/mark-read/{random.choice(NOTIFICATION_UIDS)}", + f"/notifications/mark-read/{random.choice(self.notification_uids)}", name="notifications/mark-read", ) @task(1) def delete_comment(self): - if not COMMENT_UIDS: + if not self.own_comment_uids: return - self.client.post( - f"/comments/delete/{random.choice(COMMENT_UIDS)}", - name="comments/delete", - ) + uid = self.own_comment_uids.pop() + if uid in COMMENT_UIDS: + COMMENT_UIDS.remove(uid) + self.client.post(f"/comments/delete/{uid}", name="comments/delete") # ── owner-scoped mutations ────────────────────────────────── @@ -1601,6 +1655,516 @@ class DevPlaceUser(HttpUser): if comment_uid and comment_uid.group(1) not in COMMENT_UIDS: COMMENT_UIDS.append(comment_uid.group(1)) + # ── block / mute ──────────────────────────────────────────── + + @task(1) + def block_user(self): + targets = [u for u in ALL_USERNAMES if u != self.username] + if not targets: + return + self.client.post(f"/block/{random.choice(targets)}", name="block/[username]") + + @task(1) + def unblock_user(self): + targets = [u for u in ALL_USERNAMES if u != self.username] + if not targets: + return + self.client.post( + f"/block/unblock/{random.choice(targets)}", name="block/unblock" + ) + + @task(1) + def mute_user(self): + targets = [u for u in ALL_USERNAMES if u != self.username] + if not targets: + return + self.client.post(f"/mute/{random.choice(targets)}", name="mute/[username]") + + @task(1) + def unmute_user(self): + targets = [u for u in ALL_USERNAMES if u != self.username] + if not targets: + return + self.client.post( + f"/mute/unmute/{random.choice(targets)}", name="mute/unmute" + ) + + # ── Code Farm ─────────────────────────────────────────────── + + @task(2) + def view_game_home(self): + self.client.get("/game", name="game") + + @task(2) + def view_game_state(self): + self.client.get("/game/state", name="game/state") + + @task(1) + def view_game_leaderboard(self): + board = random.choice( + ["score", "prestige", "harvests", "raids", "time_to_kernel", "fair_play"] + ) + self.client.get( + "/game/leaderboard", params={"board": board}, name="game/leaderboard" + ) + + @task(2) + def game_plant(self): + self.client.post( + "/game/plant", + data={"slot": random.randint(0, 3), "crop": "shell"}, + name="game/plant", + ) + + @task(2) + def game_harvest(self): + self.client.post( + "/game/harvest", + data={"slot": random.randint(0, 3)}, + name="game/harvest", + ) + + @task(1) + def game_buy_plot(self): + self.client.post("/game/buy-plot", name="game/buy-plot") + + @task(1) + def game_upgrade_ci(self): + self.client.post("/game/upgrade", name="game/upgrade") + + @task(1) + def game_fertilize(self): + self.client.post( + "/game/fertilize", + data={"slot": random.randint(0, 3)}, + name="game/fertilize", + ) + + @task(1) + def game_daily(self): + self.client.post("/game/daily", name="game/daily") + + @task(1) + def game_claim_grant(self): + self.client.post("/game/grant", name="game/grant") + + @task(1) + def game_perk(self): + perk = random.choice(["yield", "growth", "discount", "xp"]) + self.client.post("/game/perk", data={"perk": perk}, name="game/perk") + + @task(1) + def game_quests_claim(self): + quest = random.choice(["plant", "harvest", "water", "earn"]) + self.client.post( + "/game/quests/claim", + data={"quest": quest, "scope": "daily"}, + name="game/quests/claim", + ) + + @task(1) + def game_defense(self): + self.client.post( + f"/game/defense/{random.choice(['upgrade', 'downgrade'])}", + name="game/defense", + ) + + @task(1) + def game_infrastructure_buy(self): + key = random.choice(["registry", "canary", "observability"]) + self.client.post( + "/game/infrastructure/buy", data={"key": key}, name="game/infrastructure/buy" + ) + + @task(1) + def game_mastery(self): + key = random.choice(["autoreplant", "analytics", "contracts"]) + self.client.post("/game/mastery", data={"key": key}, name="game/mastery") + + @task(1) + def game_cosmetics(self): + key = random.choice( + [ + "title_architect", + "title_refactorer", + "title_kernel_hacker", + "skin_neon", + ] + ) + if random.random() < 0.5: + self.client.post( + "/game/cosmetics/buy", data={"key": key}, name="game/cosmetics/buy" + ) + else: + self.client.post( + "/game/cosmetics/equip", data={"key": key}, name="game/cosmetics/equip" + ) + + @task(2) + def view_game_farm(self): + if not ALL_USERNAMES: + return + self.client.get( + f"/game/farm/{random.choice(ALL_USERNAMES)}", name="game/farm/[username]" + ) + + @task(1) + def game_water_farm(self): + targets = [u for u in ALL_USERNAMES if u != self.username] + if not targets: + return + self.client.post( + f"/game/farm/{random.choice(targets)}/water", + data={"slot": random.randint(0, 3)}, + name="game/farm/[username]/water", + ) + + @task(1) + def game_steal_farm(self): + targets = [u for u in ALL_USERNAMES if u != self.username] + if not targets: + return + self.client.post( + f"/game/farm/{random.choice(targets)}/steal", + data={"slot": random.randint(0, 3)}, + name="game/farm/[username]/steal", + ) + + # ── Quizzes ───────────────────────────────────────────────── + + @task(2) + def view_quizzes_hub(self): + params = {"filter": random.choice(["all", "todo", "done", "mine", "drafts"])} + self.client.get("/quizzes", params=params, name="quizzes") + + @task(1) + def view_quiz_scoreboard(self): + self.client.get("/quizzes/scoreboard", name="quizzes/scoreboard") + + @task(2) + def view_quiz_detail(self): + if not QUIZ_SLUGS: + return + self.client.get(f"/quizzes/{random.choice(QUIZ_SLUGS)}", name="quizzes/[slug]") + + @task(1) + def view_quiz_leaderboard(self): + if not QUIZ_SLUGS: + return + self.client.get( + f"/quizzes/{random.choice(QUIZ_SLUGS)}/leaderboard", + name="quizzes/[slug]/leaderboard", + ) + + @task(1) + def view_quiz_export(self): + if not QUIZ_SLUGS: + return + self.client.get( + f"/quizzes/{random.choice(QUIZ_SLUGS)}/export", name="quizzes/[slug]/export" + ) + + @task(1) + def view_quiz_builder(self): + if not self.own_quiz_slugs: + return + slug = random.choice(self.own_quiz_slugs) + self.client.get(f"/quizzes/{slug}/edit", name="quizzes/[slug]/edit") + + @task(1) + def view_quiz_new_page(self): + self.client.get("/quizzes/new", name="quizzes/new") + + @task(1) + def create_quiz(self): + with self.client.post( + "/quizzes/create", + json={ + "title": f"Quiz {uuid.uuid4().hex[:6]}", + "description": "Load test quiz.", + }, + catch_response=True, + name="quizzes/create", + ) as resp: + if resp.status_code != 200: + resp.failure(f"quiz create: status={resp.status_code}") + return + try: + slug = (resp.json().get("data") or {}).get("slug") + except (ValueError, RequestException, AttributeError): + slug = None + if slug: + self.own_quiz_slugs.append(slug) + resp.success() + + @task(1) + def edit_quiz_settings(self): + if not self.own_quiz_slugs: + return + slug = random.choice(self.own_quiz_slugs) + self.client.post( + f"/quizzes/edit/{slug}", + json={ + "title": f"Edited quiz {uuid.uuid4().hex[:6]}", + "description": "Edited load test quiz.", + }, + name="quizzes/edit", + ) + + @task(2) + def add_quiz_question(self): + if not self.own_quiz_slugs: + return + slug = random.choice(self.own_quiz_slugs) + answer = random.choice([True, False]) + with self.client.post( + f"/quizzes/{slug}/questions", + json={ + "kind": "true_false", + "prompt": f"Load test question {uuid.uuid4().hex[:6]}?", + "correct_boolean": answer, + "points": 1, + }, + catch_response=True, + name="quizzes/[slug]/questions", + ) as resp: + if resp.status_code != 200: + resp.failure(f"quiz question add: status={resp.status_code}") + return + try: + uid = (resp.json().get("data") or {}).get("uid") + except (ValueError, RequestException, AttributeError): + uid = None + if uid: + QUIZ_QUESTIONS.setdefault(slug, []).append(uid) + resp.success() + + @task(1) + def publish_quiz(self): + ready = [s for s in self.own_quiz_slugs if QUIZ_QUESTIONS.get(s)] + if not ready: + return + slug = random.choice(ready) + with self.client.post( + f"/quizzes/{slug}/publish", + headers={"Accept": "application/json"}, + catch_response=True, + name="quizzes/[slug]/publish", + ) as resp: + if resp.status_code != 200: + resp.failure(f"quiz publish: status={resp.status_code}") + return + try: + ok = bool(resp.json().get("ok")) + except (ValueError, RequestException, AttributeError): + ok = False + if not ok: + resp.failure(f"quiz publish rejected: {resp.text}") + return + if slug not in QUIZ_SLUGS: + QUIZ_SLUGS.append(slug) + self.own_quiz_slugs.remove(slug) + resp.success() + + @task(1) + def delete_quiz(self): + if not self.own_quiz_slugs: + return + slug = self.own_quiz_slugs.pop() + QUIZ_QUESTIONS.pop(slug, None) + self.client.post(f"/quizzes/delete/{slug}", name="quizzes/delete") + + @task(2) + def play_quiz(self): + candidates = [s for s in QUIZ_SLUGS if QUIZ_QUESTIONS.get(s)] + if not candidates: + return + slug = random.choice(candidates) + with self.client.post( + f"/quizzes/{slug}/attempts", + headers={"Accept": "application/json"}, + catch_response=True, + name="quizzes/[slug]/attempts", + ) as resp: + if resp.status_code != 200: + resp.failure(f"quiz attempt start: status={resp.status_code}") + return + try: + attempt_uid = (resp.json().get("data") or {}).get("uid") + except (ValueError, RequestException, AttributeError): + attempt_uid = None + resp.success() + if not attempt_uid: + return + for question_uid in QUIZ_QUESTIONS.get(slug, []): + self.client.post( + f"/quizzes/{slug}/attempts/{attempt_uid}/answer", + json={ + "question_uid": question_uid, + "answer_text": random.choice(["true", "false"]), + }, + name="quizzes/[slug]/attempts/[uid]/answer", + ) + self.client.get( + f"/quizzes/{slug}/attempts/{attempt_uid}", + name="quizzes/[slug]/attempts/[uid]", + ) + self.client.post( + f"/quizzes/{slug}/attempts/{attempt_uid}/finish", + name="quizzes/[slug]/attempts/[uid]/finish", + ) + self.client.get( + f"/quizzes/{slug}/attempts/{attempt_uid}/results", + name="quizzes/[slug]/attempts/[uid]/results", + ) + + # ── Opinion Wars ──────────────────────────────────────────── + + @task(2) + def view_battles(self): + params = {"filter": random.choice(["active", "ended", "mine"])} + self.client.get("/battles", params=params, name="battles") + + @task(2) + def view_battle_state(self): + if not BATTLE_UIDS: + return + self.client.get(f"/battles/{random.choice(BATTLE_UIDS)}", name="battles/[uid]") + + @task(1) + def view_battle_events(self): + if not BATTLE_UIDS: + return + self.client.get( + f"/battles/{random.choice(BATTLE_UIDS)}/events", name="battles/[uid]/events" + ) + + @task(1) + def join_battle(self): + if not BATTLE_UIDS: + return + self.client.post( + f"/battles/{random.choice(BATTLE_UIDS)}/join", + data={"faction": random.choice(["a", "b"])}, + name="battles/[uid]/join", + ) + + @task(1) + def fight_battle(self): + if not BATTLE_UIDS: + return + self.client.post( + f"/battles/{random.choice(BATTLE_UIDS)}/fight", name="battles/[uid]/fight" + ) + + # ── developer tools landing pages ─────────────────────────── + + @task(1) + def view_tools_landing(self): + self.client.get( + random.choice(["/tools/deepsearch", "/tools/isslop", "/tools/isslop/list"]), + name="tools/[page]", + ) + + @task(1) + def call_xmlrpc(self): + self.client.get("/xmlrpc", name="xmlrpc") + body = xmlrpc.client.dumps((), methodname="system.listMethods").encode() + with self.client.post( + "/xmlrpc", + data=body, + headers={"Content-Type": "text/xml"}, + catch_response=True, + name="xmlrpc/call", + ) as resp: + if resp.status_code == 200: + resp.success() + else: + resp.failure(f"xmlrpc call: status={resp.status_code}") + + # ── access tokens, uploads, profile sub-settings, messages ── + + @task(1) + def request_access_token(self): + if not SEED_USERS: + return + seed = random.choice(SEED_USERS) + self.client.post( + "/auth/token", + json={"email": seed["email"], "password": PASSWORD}, + name="auth/token", + ) + + @task(1) + def list_uploads(self): + self.client.get("/uploads", name="uploads") + + @task(1) + def get_upload(self): + if not self.own_media_uids: + return + self.client.get( + f"/uploads/{random.choice(self.own_media_uids)}", name="uploads/[uid]" + ) + + @task(1) + def rename_upload(self): + if not self.own_media_uids: + return + uid = random.choice(self.own_media_uids) + self.client.patch( + f"/uploads/{uid}", + json={"filename": f"renamed_{uuid.uuid4().hex[:6]}.txt"}, + name="uploads/[uid]/rename", + ) + + @task(1) + def set_ai_correction(self): + self.client.post( + f"/profile/{self.username}/ai-correction", + json={"enabled": random.choice([True, False]), "sync": False}, + name="profile/[username]/ai-correction", + ) + + @task(1) + def set_ai_modifier(self): + self.client.post( + f"/profile/{self.username}/ai-modifier", + json={"enabled": random.choice([True, False]), "sync": True}, + name="profile/[username]/ai-modifier", + ) + + @task(1) + def set_interactions(self): + self.client.post( + f"/profile/{self.username}/interactions", + json={"enabled": random.choice([True, False])}, + name="profile/[username]/interactions", + ) + + @task(1) + def telegram_pairing(self): + self.client.post( + f"/profile/{self.username}/telegram", + json={"action": random.choice(["request", "unpair"])}, + name="profile/[username]/telegram", + ) + + @task(1) + def regenerate_avatar(self): + self.client.post( + f"/profile/{self.username}/regenerate-avatar", + name="profile/[username]/regenerate-avatar", + ) + + @task(1) + def view_conversations_json(self): + self.client.get("/messages/conversations", name="messages/conversations") + + @task(1) + def request_ws_ticket(self): + self.client.post("/messages/ws-ticket", name="messages/ws-ticket") + class AdminUser(HttpUser): weight = 1 @@ -1804,6 +2368,23 @@ class AdminUser(HttpUser): return self.client.post(f"/admin/users/{uid}/toggle", name="admin/users/toggle") + @task(1) + def view_pubsub_topics(self): + self.client.get("/pubsub/topics", name="pubsub/topics") + + @task(1) + def publish_pubsub(self): + with self.client.post( + "/pubsub/publish", + json={"topic": "public.locust.loadtest", "data": {"ping": True}}, + catch_response=True, + name="pubsub/publish", + ) as resp: + if resp.status_code in (200, 409): + resp.success() + else: + resp.failure(f"pubsub publish: status={resp.status_code}") + class AnonymousUser(HttpUser): weight = 3 @@ -1890,3 +2471,295 @@ class AnonymousUser(HttpUser): @task(1) def login_with_next(self): self.client.get("/auth/login", params={"next": "/feed"}, name="auth/login?next") + + +class DevRantUser(HttpUser): + weight = 4 + wait_time = between(1, 5) + + def on_start(self): + assign_client_ip(self.client) + self.token_id = None + self.token_key = None + self.own_rant_ids = [] + if random.random() < 0.3 and SEED_USERS: + username = random.choice(SEED_USERS)["username"] + else: + username = random_username() + email = f"{username}@locust.devplace" + if not do_signup(self.client, username, email): + return + ALL_USERNAMES.append(username) + self.username = username + with self.client.post( + "/api/users/auth-token", + data={"username": username, "password": PASSWORD}, + catch_response=True, + name="api/users/auth-token", + ) as resp: + if resp.status_code != 200: + resp.failure(f"devrant auth-token: status={resp.status_code}") + return + try: + payload = resp.json() + except (ValueError, RequestException): + payload = {} + token = payload.get("auth_token") or {} + if payload.get("success") and token.get("id") and token.get("key"): + self.token_id = token["id"] + self.token_key = token["key"] + resp.success() + else: + resp.failure(f"devrant auth-token: unexpected payload {payload}") + + def _auth(self): + return {"token_id": self.token_id, "token_key": self.token_key} + + @task(1) + def dr_register(self): + username = random_username() + email = f"{username}@locust.devplace" + with self.client.post( + "/api/users", + data={"username": username, "email": email, "password": PASSWORD}, + catch_response=True, + name="api/users/register", + ) as resp: + if resp.status_code != 200: + resp.failure(f"devrant register: status={resp.status_code}") + return + try: + payload = resp.json() + except (ValueError, RequestException): + payload = {} + if payload.get("success"): + ALL_USERNAMES.append(username) + resp.success() + else: + resp.failure(f"devrant register: {payload}") + + @task(5) + def dr_feed(self): + if not self.token_id: + return + params = { + **self._auth(), + "sort": random.choice(["algo", "recent", "top"]), + "limit": 20, + } + self.client.get("/api/devrant/rants", params=params, name="api/devrant/rants") + + @task(1) + def dr_search(self): + if not self.token_id: + return + term = random.choice(ALL_USERNAMES)[:4] if ALL_USERNAMES else "lu" + self.client.get( + "/api/devrant/search", + params={**self._auth(), "term": term}, + name="api/devrant/search", + ) + + @task(2) + def dr_get_user_id(self): + if not ALL_USERNAMES: + return + self.client.get( + "/api/get-user-id", + params={"username": random.choice(ALL_USERNAMES)}, + name="api/get-user-id", + ) + + @task(1) + def dr_profile(self): + if not self.token_id: + return + user_id = None + with self.client.get( + "/api/get-user-id", + params={"username": self.username}, + catch_response=True, + name="api/get-user-id", + ) as resp: + if resp.status_code == 200: + try: + user_id = resp.json().get("user_id") + except (ValueError, RequestException, AttributeError): + user_id = None + resp.success() + else: + resp.failure(f"get-user-id: status={resp.status_code}") + if not user_id: + return + self.client.get( + f"/api/users/{user_id}", params=self._auth(), name="api/users/[id]" + ) + + @task(2) + def dr_create_rant(self): + if not self.token_id: + return + with self.client.post( + "/api/devrant/rants", + params=self._auth(), + data={"rant": f"Load test rant {uuid.uuid4().hex[:6]}", "tags": "locust"}, + catch_response=True, + name="api/devrant/rants/create", + ) as resp: + if resp.status_code != 200: + resp.failure(f"devrant rant create: status={resp.status_code}") + return + try: + payload = resp.json() + except (ValueError, RequestException): + payload = {} + if not payload.get("success"): + resp.failure(f"devrant rant create: {payload}") + return + rant_id = payload.get("rant_id") + if rant_id: + DR_RANT_IDS.append(rant_id) + self.own_rant_ids.append(rant_id) + resp.success() + + @task(3) + def dr_get_rant(self): + if not DR_RANT_IDS: + return + rant_id = random.choice(DR_RANT_IDS) + with self.client.get( + f"/api/devrant/rants/{rant_id}", + params=self._auth() if self.token_id else {}, + catch_response=True, + name="api/devrant/rants/[id]", + ) as resp: + if resp.status_code != 200: + resp.failure(f"get rant: status={resp.status_code}") + return + try: + payload = resp.json() + except (ValueError, RequestException): + payload = {} + for comment in payload.get("comments") or []: + cid = comment.get("id") + if cid and cid not in DR_COMMENT_IDS: + DR_COMMENT_IDS.append(cid) + resp.success() + + @task(1) + def dr_edit_own_rant(self): + if not self.token_id or not self.own_rant_ids: + return + rant_id = random.choice(self.own_rant_ids) + self.client.post( + f"/api/devrant/rants/{rant_id}", + params=self._auth(), + data={"rant": f"Edited {uuid.uuid4().hex[:6]}"}, + name="api/devrant/rants/[id]/edit", + ) + + @task(1) + def dr_delete_own_rant(self): + if not self.token_id or not self.own_rant_ids: + return + rant_id = self.own_rant_ids.pop() + if rant_id in DR_RANT_IDS: + DR_RANT_IDS.remove(rant_id) + self.client.delete( + f"/api/devrant/rants/{rant_id}", + params=self._auth(), + name="api/devrant/rants/[id]/delete", + ) + + @task(2) + def dr_vote_rant(self): + if not self.token_id or not DR_RANT_IDS: + return + self.client.post( + f"/api/devrant/rants/{random.choice(DR_RANT_IDS)}/vote", + params=self._auth(), + data={"vote": random.choice([1, -1])}, + name="api/devrant/rants/[id]/vote", + ) + + @task(1) + def dr_favorite_rant(self): + if not self.token_id or not DR_RANT_IDS: + return + rant_id = random.choice(DR_RANT_IDS) + endpoint = random.choice(["favorite", "unfavorite"]) + self.client.post( + f"/api/devrant/rants/{rant_id}/{endpoint}", + params=self._auth(), + name=f"api/devrant/rants/[id]/{endpoint}", + ) + + @task(2) + def dr_comment_rant(self): + if not self.token_id or not DR_RANT_IDS: + return + self.client.post( + f"/api/devrant/rants/{random.choice(DR_RANT_IDS)}/comments", + params=self._auth(), + data={"comment": f"Devrant comment {uuid.uuid4().hex[:6]}"}, + name="api/devrant/rants/[id]/comments", + ) + + @task(1) + def dr_get_comment(self): + if not DR_COMMENT_IDS: + return + self.client.get( + f"/api/comments/{random.choice(DR_COMMENT_IDS)}", + params=self._auth() if self.token_id else {}, + name="api/comments/[id]", + ) + + @task(1) + def dr_vote_comment(self): + if not self.token_id or not DR_COMMENT_IDS: + return + self.client.post( + f"/api/comments/{random.choice(DR_COMMENT_IDS)}/vote", + params=self._auth(), + data={"vote": random.choice([1, -1])}, + name="api/comments/[id]/vote", + ) + + @task(1) + def dr_notif_feed(self): + if not self.token_id: + return + self.client.get( + "/api/users/me/notif-feed", + params=self._auth(), + name="api/users/me/notif-feed", + ) + + @task(1) + def dr_clear_notif_feed(self): + if not self.token_id: + return + self.client.delete( + "/api/users/me/notif-feed", + params=self._auth(), + name="api/users/me/notif-feed/clear", + ) + + @task(1) + def dr_avatar(self): + seed = random.choice(ALL_USERNAMES) if ALL_USERNAMES else "anon" + self.client.get(f"/api/avatars/u/{seed}.png", name="api/avatars/u/[seed]") + + @task(1) + def dr_misc_stubs(self): + if not self.token_id: + return + endpoint = random.choice( + [ + "/api/users/forgot-password", + "/api/users/me/mark-news-read", + "/api/users/me/resend-confirm", + ] + ) + self.client.post(endpoint, params=self._auth(), name="api/users/me/stub") diff --git a/tests/api/issues/planning.py b/tests/api/issues/planning.py index 67a9994..44bf81b 100644 --- a/tests/api/issues/planning.py +++ b/tests/api/issues/planning.py @@ -83,3 +83,88 @@ def test_planning_enqueue_without_numbers_is_all_open(seeded_db): refresh_snapshot() job = get_table("jobs").find_one(uid=uid) assert json.loads(job["payload"])["numbers"] == [] + + +def _mark_done(uid, result): + get_table("jobs").update( + {"uid": uid, "status": "done", "result": json.dumps(result)}, ["uid"] + ) + refresh_snapshot() + + +def test_planning_download_serves_the_report_file(seeded_db): + from devplacepy.config import PLANNING_REPORTS_DIR + from devplacepy.services.jobs import queue + + admin = _admin(seeded_db) + uid = queue.enqueue( + "planning", {"numbers": []}, "user", "planning-download-owner", "open-tickets-plan" + ) + report_dir = PLANNING_REPORTS_DIR / "ab" / "cd" + report_dir.mkdir(parents=True, exist_ok=True) + report_path = report_dir / f"{uid}.open-tickets-plan.md" + report_path.write_text("# Plan\n\nDo the thing.\n", encoding="utf-8") + try: + _mark_done( + uid, + { + "download_url": f"/issues/planning/{uid}/download", + "local_path": str(report_path), + "final_name": "open-tickets-plan.md", + "markdown": "# Plan\n\nDo the thing.\n", + "ai_used": False, + "item_count": 0, + "bytes_out": report_path.stat().st_size, + }, + ) + response = admin.get(f"{BASE_URL}/issues/planning/{uid}/download") + assert response.status_code == 200, response.text[:300] + assert response.headers["content-type"].startswith("text/markdown") + assert "Do the thing." in response.text + finally: + get_table("jobs").delete(uid=uid) + report_path.unlink(missing_ok=True) + + +def test_planning_download_rejects_a_local_path_outside_the_reports_dir(seeded_db): + from devplacepy.config import DATA_DIR + from devplacepy.services.jobs import queue + + admin = _admin(seeded_db) + uid = queue.enqueue( + "planning", {"numbers": []}, "user", "planning-traversal-owner", "open-tickets-plan" + ) + escape_path = DATA_DIR / f"escaped-{uid}.md" + escape_path.write_text("secret", encoding="utf-8") + try: + _mark_done( + uid, + { + "download_url": f"/issues/planning/{uid}/download", + "local_path": str(escape_path), + "final_name": "escaped.md", + "markdown": "secret", + "ai_used": False, + "item_count": 0, + "bytes_out": escape_path.stat().st_size, + }, + ) + response = admin.get(f"{BASE_URL}/issues/planning/{uid}/download") + assert response.status_code == 404 + finally: + get_table("jobs").delete(uid=uid) + escape_path.unlink(missing_ok=True) + + +def test_planning_download_pending_job_is_404(seeded_db): + from devplacepy.services.jobs import queue + + admin = _admin(seeded_db) + uid = queue.enqueue( + "planning", {"numbers": []}, "user", "planning-pending-owner", "open-tickets-plan" + ) + try: + response = admin.get(f"{BASE_URL}/issues/planning/{uid}/download") + assert response.status_code == 404 + finally: + get_table("jobs").delete(uid=uid) diff --git a/tests/e2e/admin/deviitasks.py b/tests/e2e/admin/deviitasks.py new file mode 100644 index 0000000..c5d70c4 --- /dev/null +++ b/tests/e2e/admin/deviitasks.py @@ -0,0 +1,95 @@ +# retoor + +from devplacepy.database import db, get_table, refresh_snapshot +from devplacepy.services.devii.tasks.schedule import now_utc, to_iso +from devplacepy.utils import generate_uid +from tests.conftest import BASE_URL + + +def _admin_uid(): + refresh_snapshot() + return get_table("users").find_one(username="alice_test")["uid"] + + +def _seed_admin_task(label, enabled=True): + uid = generate_uid() + db["devii_tasks"].insert( + { + "uid": uid, + "owner_kind": "user", + "owner_id": _admin_uid(), + "label": label, + "prompt": "work", + "enabled": enabled, + "status": "pending" if enabled else "disabled", + "kind": "interval", + "every_seconds": 900, + "max_runs": 10, + "run_count": 1, + "failure_count": 0, + "created_at": to_iso(now_utc()), + "next_run_at": to_iso(now_utc()), + "expires_at": None, + "deleted_at": None, + "deleted_by": None, + } + ) + return uid + + +def test_devii_tasks_page_renders_for_admin(alice): + page, _ = alice + page.goto(f"{BASE_URL}/admin/devii-tasks", wait_until="domcontentloaded") + page.locator(".admin-toolbar h2:has-text('Devii tasks')").wait_for(state="visible") + assert page.locator("table.admin-table").count() == 1 + assert page.locator("nav.admin-tabs a.admin-tab").count() == 3 + + +def test_devii_tasks_member_redirects_to_feed(bob): + page, _ = bob + page.goto(f"{BASE_URL}/admin/devii-tasks", wait_until="domcontentloaded") + assert page.url.rstrip("/") == f"{BASE_URL}/feed" + + +def test_devii_tasks_disable_flow(alice): + page, _ = alice + label = f"e2e-disable-{generate_uid()[:8]}" + uid = _seed_admin_task(label, enabled=True) + try: + page.goto(f"{BASE_URL}/admin/devii-tasks", wait_until="domcontentloaded") + row = page.locator("table.admin-table tbody tr", has_text=label) + row.wait_for(state="visible") + row.locator("form.admin-inline-form button:has-text('Disable')").click() + page.wait_for_url("**/admin/devii-tasks", wait_until="domcontentloaded") + + refresh_snapshot() + after = db["devii_tasks"].find_one(uid=uid) + assert after["enabled"] is False + assert after["status"] == "disabled" + + page.goto(f"{BASE_URL}/admin/devii-tasks?state=inactive", wait_until="domcontentloaded") + disabled_row = page.locator("table.admin-table tbody tr", has_text=label) + disabled_row.wait_for(state="visible") + assert disabled_row.locator("button:has-text('Disable')").count() == 0 + finally: + db["devii_tasks"].delete(uid=uid) + + +def test_devii_tasks_delete_flow(alice): + page, _ = alice + label = f"e2e-delete-{generate_uid()[:8]}" + uid = _seed_admin_task(label, enabled=True) + try: + page.goto(f"{BASE_URL}/admin/devii-tasks", wait_until="domcontentloaded") + row = page.locator("table.admin-table tbody tr", has_text=label) + row.wait_for(state="visible") + + row.locator("form.admin-inline-form button:has-text('Delete')").click() + page.locator(".dialog-overlay.visible .dialog-confirm").click() + page.wait_for_url("**/admin/devii-tasks", wait_until="domcontentloaded") + + assert page.locator("table.admin-table tbody tr", has_text=label).count() == 0 + refresh_snapshot() + assert db["devii_tasks"].find_one(uid=uid, deleted_at=None) is None + finally: + db["devii_tasks"].delete(uid=uid) diff --git a/tests/e2e/admin/trash.py b/tests/e2e/admin/trash.py new file mode 100644 index 0000000..b321b59 --- /dev/null +++ b/tests/e2e/admin/trash.py @@ -0,0 +1,66 @@ +# retoor + +import time + +import requests + +from tests.conftest import BASE_URL +from devplacepy.database import get_table, refresh_snapshot + +_counter_admin_trash = [0] + + +def _unique_admin_trash(prefix="atr"): + _counter_admin_trash[0] += 1 + return f"{prefix}{int(time.time() * 1000)}{_counter_admin_trash[0]}" + + +def _bob_key(): + refresh_snapshot() + return get_table("users").find_one(username="bob_test")["api_key"] + + +def _seed_deleted_post(): + key = _bob_key() + headers = {"Accept": "application/json", "X-API-KEY": key} + title = _unique_admin_trash("atrp") + created = requests.post( + f"{BASE_URL}/posts/create", + headers=headers, + data={"title": title, "content": "admin trash e2e post body", "topic": "devlog"}, + ).json()["data"] + requests.post( + f"{BASE_URL}/posts/delete/{created['slug']}", headers=headers, allow_redirects=False + ) + return created, title + + +def test_trash_page_renders_for_admin(alice): + page, _ = alice + page.goto(f"{BASE_URL}/admin/trash", wait_until="domcontentloaded") + page.locator(".admin-toolbar h2:has-text('Trash')").wait_for(state="visible") + assert page.locator("table.admin-table").count() == 1 + assert page.locator("nav.admin-tabs a.admin-tab").count() >= 1 + + +def test_trash_member_redirects_to_feed(bob): + page, _ = bob + page.goto(f"{BASE_URL}/admin/trash", wait_until="domcontentloaded") + assert page.url.rstrip("/") == f"{BASE_URL}/feed" + + +def test_trash_restore_removes_row_and_revives_the_post(alice): + page, _ = alice + post, title = _seed_deleted_post() + page.goto(f"{BASE_URL}/admin/trash?table=posts", wait_until="domcontentloaded") + row = page.locator("table.admin-table tbody tr", has_text=title) + row.wait_for(state="visible") + + row.locator("form.admin-inline-form button:has-text('Restore')").click() + page.wait_for_url("**/admin/trash?table=posts", wait_until="domcontentloaded") + + assert page.locator("table.admin-table tbody tr", has_text=title).count() == 0 + + refresh_snapshot() + revived = get_table("posts").find_one(uid=post["uid"]) + assert revived["deleted_at"] is None diff --git a/tests/e2e/profile/search.py b/tests/e2e/profile/search.py index 6a23556..bf43e19 100644 --- a/tests/e2e/profile/search.py +++ b/tests/e2e/profile/search.py @@ -640,6 +640,64 @@ def test_admin_edits_other_user(alice): reset_notification_prefs(target["uid"]) +def _await_notification_enabled(uid, notification_type, channel, expected): + import time as _time + + deadline = _time.time() + 5.0 + current = notification_enabled(uid, notification_type, channel) + while current != expected and _time.time() < deadline: + _time.sleep(0.2) + current = notification_enabled(uid, notification_type, channel) + return current + + +def test_owner_reset_via_http_clears_custom_prefs(bob): + _, user = bob + target = _user_notification_prefs(user["username"]) + set_notification_pref(target["uid"], "mention", "push", False) + try: + assert _await_notification_enabled(target["uid"], "mention", "push", False) is False + + from devplacepy.database import get_table, refresh_snapshot + + audit_table = get_table("audit_log") + before = audit_table.count( + event_key="profile.notification.reset", target_uid=target["uid"] + ) + + response = requests.post( + f"{BASE_URL}/profile/{target['username']}/notifications/reset", + headers={"Accept": "application/json", "X-API-KEY": target["api_key"]}, + allow_redirects=False, + ) + assert response.status_code == 200 + body = response.json() + assert body["ok"] is True + assert body["redirect"] == f"/profile/{target['username']}?tab=notifications" + + assert _await_notification_enabled(target["uid"], "mention", "push", True) is True + + refresh_snapshot() + after = audit_table.count( + event_key="profile.notification.reset", target_uid=target["uid"] + ) + assert after == before + 1 + finally: + reset_notification_prefs(target["uid"]) + + +def test_non_owner_non_admin_reset_forbidden(bob): + _, user = bob + member = _user_notification_prefs(user["username"]) + target = _user_notification_prefs("alice_test") + response = requests.post( + f"{BASE_URL}/profile/{target['username']}/notifications/reset", + headers={"Accept": "application/json", "X-API-KEY": member["api_key"]}, + allow_redirects=False, + ) + assert response.status_code == 403 + + def test_profile_search_returns_results(alice): page, _ = alice resp = page.request.get(f"{BASE_URL}/profile/search?q=bob")