2026-06-13 16:32:33 +02:00
|
|
|
# retoor <retoor@molodetz.nl>
|
|
|
|
|
|
feat: add leaderboard router, gamification backfill, and shared content creation helpers
Introduce a new `/leaderboard` endpoint ranking top 50 members by total stars, wire it into the app router and documentation. Implement `_backfill_gamification()` in `database.py` to compute XP/levels for existing accounts from prior posts, comments, votes, and follows. Extract `is_owner()`, `create_content_item()`, and `detail_context()` into `content.py` to centralize content creation, reward awarding, mention notifications, and attachment linking. Update comment creation/deletion in `comments.py` to use `award_rewards()` with `XP_COMMENT` and the new `is_owner()` helper. Add shared template partials documentation for vote bars, star buttons, post headers, and topic selectors to `AGENTS.md`.
2026-05-30 20:16:39 +02:00
|
|
|
import re
|
2026-06-05 05:36:18 +02:00
|
|
|
from uuid import uuid4
|
|
|
|
|
from datetime import datetime, timedelta, timezone
|
2026-05-27 21:06:18 +02:00
|
|
|
from playwright.sync_api import expect
|
2026-05-23 03:21:55 +02:00
|
|
|
from tests.conftest import BASE_URL, assert_share_copies
|
2026-06-05 05:36:18 +02:00
|
|
|
from devplacepy.database import get_table
|
|
|
|
|
from devplacepy.utils import make_combined_slug
|
2026-06-13 16:32:33 +02:00
|
|
|
def _seed_posts(count):
|
|
|
|
|
suite = uuid4().hex[:8]
|
|
|
|
|
topic = f"pag{suite}"
|
|
|
|
|
users = get_table("users")
|
|
|
|
|
posts = get_table("posts")
|
|
|
|
|
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
|
|
|
for i in range(count):
|
|
|
|
|
owner = str(uuid4())
|
|
|
|
|
users.insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": owner,
|
|
|
|
|
"username": f"pag_{suite}_{i}",
|
Add the trust and safety subsystem and the App Store compliance work
Implements the moderation and consent obligations a social platform carries,
so the web version and any client that speaks to it enforce the same rules.
Moderation core (services/moderation/, database/moderation.py): a reportable
target registry, the content filter and its choke points, the report queue with
atomic resolution, enforcement actions, consent tracking, maturity gating, and
account deletion with a grace window.
Surfaces: POST /reports plus the member report list, /admin/moderation and the
per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and
account deletion under /profile, the report button and dialog partials, the
maturity gate, and the moderation stylesheet and ReportDialog client.
Every user-generated surface stays reportable by construction: new content tables
are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a
reason, and the registry test fails the suite on anything left unclassified.
Docs: community guidelines, content moderation, intellectual property, privacy,
terms, contact, and the admin-only moderation operations page, plus the
moderation API group and the Devii moderation actions.
Compliance record: applecomp.md is the requirement register, applechanges.md the
gap analysis against this codebase, and appleimpl.md the implementation design
they resolve to.
Tests cover the report flow, admin moderation, consent, account deletion, terms
acceptance, workspaces, and the registry invariant across the unit, api, and e2e
tiers.
2026-08-09 00:18:20 +02:00
|
|
|
"terms_version": "1",
|
2026-06-13 16:32:33 +02:00
|
|
|
"email": f"{suite}_{i}@test.devplace",
|
|
|
|
|
"password_hash": "x",
|
|
|
|
|
"role": "Member",
|
|
|
|
|
"is_active": True,
|
|
|
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
uid = str(uuid4())
|
|
|
|
|
content = f"Paginated post {i}"
|
|
|
|
|
posts.insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": uid,
|
|
|
|
|
"user_uid": owner,
|
|
|
|
|
"slug": make_combined_slug(content, uid),
|
|
|
|
|
"title": None,
|
|
|
|
|
"content": content,
|
|
|
|
|
"topic": topic,
|
|
|
|
|
"project_uid": None,
|
|
|
|
|
"image": None,
|
|
|
|
|
"stars": 0,
|
|
|
|
|
"created_at": (base - timedelta(seconds=i)).isoformat(),
|
|
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return topic
|
|
|
|
|
def _seed_post_with_comments(comment_texts, topic="devlog"):
|
|
|
|
|
owner = str(uuid4())
|
|
|
|
|
get_table("users").insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": owner,
|
|
|
|
|
"username": f"seed_{owner[:8]}",
|
Add the trust and safety subsystem and the App Store compliance work
Implements the moderation and consent obligations a social platform carries,
so the web version and any client that speaks to it enforce the same rules.
Moderation core (services/moderation/, database/moderation.py): a reportable
target registry, the content filter and its choke points, the report queue with
atomic resolution, enforcement actions, consent tracking, maturity gating, and
account deletion with a grace window.
Surfaces: POST /reports plus the member report list, /admin/moderation and the
per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and
account deletion under /profile, the report button and dialog partials, the
maturity gate, and the moderation stylesheet and ReportDialog client.
Every user-generated surface stays reportable by construction: new content tables
are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a
reason, and the registry test fails the suite on anything left unclassified.
Docs: community guidelines, content moderation, intellectual property, privacy,
terms, contact, and the admin-only moderation operations page, plus the
moderation API group and the Devii moderation actions.
Compliance record: applecomp.md is the requirement register, applechanges.md the
gap analysis against this codebase, and appleimpl.md the implementation design
they resolve to.
Tests cover the report flow, admin moderation, consent, account deletion, terms
acceptance, workspaces, and the registry invariant across the unit, api, and e2e
tiers.
2026-08-09 00:18:20 +02:00
|
|
|
"terms_version": "1",
|
2026-06-13 16:32:33 +02:00
|
|
|
"email": f"{owner[:8]}@seed.devplace",
|
|
|
|
|
"password_hash": "x",
|
|
|
|
|
"role": "Member",
|
|
|
|
|
"is_active": True,
|
|
|
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
post_uid = str(uuid4())
|
|
|
|
|
marker = f"seedpost-{post_uid[:8]}"
|
|
|
|
|
get_table("posts").insert(
|
|
|
|
|
{
|
2026-06-14 16:46:36 +02:00
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
2026-06-13 16:32:33 +02:00
|
|
|
"uid": post_uid,
|
|
|
|
|
"user_uid": owner,
|
|
|
|
|
"slug": make_combined_slug(marker, post_uid),
|
|
|
|
|
"title": None,
|
|
|
|
|
"content": marker,
|
|
|
|
|
"topic": topic,
|
|
|
|
|
"project_uid": None,
|
|
|
|
|
"image": None,
|
|
|
|
|
"stars": 0,
|
|
|
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
comments = get_table("comments")
|
|
|
|
|
base = datetime.now(timezone.utc)
|
|
|
|
|
for i, text in enumerate(comment_texts):
|
|
|
|
|
comments.insert(
|
|
|
|
|
{
|
2026-06-14 16:46:36 +02:00
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
2026-06-13 16:32:33 +02:00
|
|
|
"uid": str(uuid4()),
|
|
|
|
|
"target_type": "post",
|
|
|
|
|
"target_uid": post_uid,
|
|
|
|
|
"post_uid": post_uid,
|
|
|
|
|
"user_uid": owner,
|
|
|
|
|
"content": text,
|
|
|
|
|
"parent_uid": None,
|
|
|
|
|
"created_at": (base + timedelta(seconds=i)).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return marker, post_uid
|
|
|
|
|
def _create_post_ui(page, content, topic="devlog"):
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
|
|
|
|
|
page.locator(".feed-fab").first.click()
|
|
|
|
|
page.check(f"input[value='{topic}']")
|
|
|
|
|
page.fill("#post-content", content)
|
|
|
|
|
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
|
|
|
|
|
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
|
|
|
|
|
def _seed_searchable_posts():
|
|
|
|
|
token = uuid4().hex[:10]
|
|
|
|
|
users = get_table("users")
|
|
|
|
|
posts = get_table("posts")
|
|
|
|
|
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
|
|
|
match_title = f"Findable post {token}"
|
|
|
|
|
other_title = f"Unrelated post {uuid4().hex[:10]}"
|
|
|
|
|
for offset, title in ((0, match_title), (1, other_title)):
|
|
|
|
|
owner = str(uuid4())
|
|
|
|
|
users.insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": owner,
|
|
|
|
|
"username": f"srch_{owner[:8]}",
|
Add the trust and safety subsystem and the App Store compliance work
Implements the moderation and consent obligations a social platform carries,
so the web version and any client that speaks to it enforce the same rules.
Moderation core (services/moderation/, database/moderation.py): a reportable
target registry, the content filter and its choke points, the report queue with
atomic resolution, enforcement actions, consent tracking, maturity gating, and
account deletion with a grace window.
Surfaces: POST /reports plus the member report list, /admin/moderation and the
per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and
account deletion under /profile, the report button and dialog partials, the
maturity gate, and the moderation stylesheet and ReportDialog client.
Every user-generated surface stays reportable by construction: new content tables
are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a
reason, and the registry test fails the suite on anything left unclassified.
Docs: community guidelines, content moderation, intellectual property, privacy,
terms, contact, and the admin-only moderation operations page, plus the
moderation API group and the Devii moderation actions.
Compliance record: applecomp.md is the requirement register, applechanges.md the
gap analysis against this codebase, and appleimpl.md the implementation design
they resolve to.
Tests cover the report flow, admin moderation, consent, account deletion, terms
acceptance, workspaces, and the registry invariant across the unit, api, and e2e
tiers.
2026-08-09 00:18:20 +02:00
|
|
|
"terms_version": "1",
|
2026-06-13 16:32:33 +02:00
|
|
|
"email": f"{owner[:8]}@test.devplace",
|
|
|
|
|
"password_hash": "x",
|
|
|
|
|
"role": "Member",
|
|
|
|
|
"is_active": True,
|
|
|
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
uid = str(uuid4())
|
|
|
|
|
posts.insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": uid,
|
|
|
|
|
"user_uid": owner,
|
|
|
|
|
"slug": make_combined_slug(title, uid),
|
|
|
|
|
"title": title,
|
|
|
|
|
"content": f"Body for {title}",
|
|
|
|
|
"topic": "devlog",
|
|
|
|
|
"project_uid": None,
|
|
|
|
|
"image": None,
|
|
|
|
|
"stars": 0,
|
|
|
|
|
"created_at": (base - timedelta(seconds=offset)).isoformat(),
|
|
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return token, match_title, other_title
|
|
|
|
|
import time
|
|
|
|
|
import requests
|
|
|
|
|
from tests.conftest import BASE_URL
|
|
|
|
|
_counter_project_files = [0]
|
|
|
|
|
def _signup_project_files():
|
|
|
|
|
_counter_project_files[0] += 1
|
|
|
|
|
name = f"pf{int(time.time() * 1000)}{_counter_project_files[0]}"
|
|
|
|
|
session = requests.Session()
|
|
|
|
|
session.post(
|
|
|
|
|
f"{BASE_URL}/auth/signup",
|
|
|
|
|
data={
|
|
|
|
|
"username": name,
|
|
|
|
|
"email": f"{name}@t.dev",
|
|
|
|
|
"password": "secret123",
|
|
|
|
|
"confirm_password": "secret123",
|
Add the trust and safety subsystem and the App Store compliance work
Implements the moderation and consent obligations a social platform carries,
so the web version and any client that speaks to it enforce the same rules.
Moderation core (services/moderation/, database/moderation.py): a reportable
target registry, the content filter and its choke points, the report queue with
atomic resolution, enforcement actions, consent tracking, maturity gating, and
account deletion with a grace window.
Surfaces: POST /reports plus the member report list, /admin/moderation and the
per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and
account deletion under /profile, the report button and dialog partials, the
maturity gate, and the moderation stylesheet and ReportDialog client.
Every user-generated surface stays reportable by construction: new content tables
are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a
reason, and the registry test fails the suite on anything left unclassified.
Docs: community guidelines, content moderation, intellectual property, privacy,
terms, contact, and the admin-only moderation operations page, plus the
moderation API group and the Devii moderation actions.
Compliance record: applecomp.md is the requirement register, applechanges.md the
gap analysis against this codebase, and appleimpl.md the implementation design
they resolve to.
Tests cover the report flow, admin moderation, consent, account deletion, terms
acceptance, workspaces, and the registry invariant across the unit, api, and e2e
tiers.
2026-08-09 00:18:20 +02:00
|
|
|
"birth_date": "1990-01-01",
|
|
|
|
|
"accept_terms": "1",
|
2026-06-13 16:32:33 +02:00
|
|
|
},
|
|
|
|
|
allow_redirects=True,
|
|
|
|
|
)
|
|
|
|
|
key = get_table("users").find_one(username=name)["api_key"]
|
|
|
|
|
return name, key
|
|
|
|
|
def _h_project_files(key):
|
|
|
|
|
return {"X-API-KEY": key, "Accept": "application/json"}
|
|
|
|
|
def _create_project_project_files(key, title):
|
|
|
|
|
r = requests.post(
|
|
|
|
|
f"{BASE_URL}/projects/create",
|
|
|
|
|
headers=_h_project_files(key),
|
|
|
|
|
data={
|
|
|
|
|
"title": title,
|
|
|
|
|
"description": "filesystem test",
|
|
|
|
|
"project_type": "software",
|
|
|
|
|
"status": "In Development",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == 200, r.text
|
|
|
|
|
return r.json()["data"]
|
|
|
|
|
def _write_project_files(key, slug, path, content):
|
|
|
|
|
return requests.post(
|
|
|
|
|
f"{BASE_URL}/projects/{slug}/files/write",
|
|
|
|
|
headers=_h_project_files(key),
|
|
|
|
|
data={"path": path, "content": content},
|
|
|
|
|
allow_redirects=False,
|
|
|
|
|
)
|
|
|
|
|
def _mkdir(key, slug, path):
|
|
|
|
|
return requests.post(
|
|
|
|
|
f"{BASE_URL}/projects/{slug}/files/mkdir",
|
|
|
|
|
headers=_h_project_files(key),
|
|
|
|
|
data={"path": path},
|
|
|
|
|
allow_redirects=False,
|
|
|
|
|
)
|
|
|
|
|
def _move(key, slug, from_path, to_path):
|
|
|
|
|
return requests.post(
|
|
|
|
|
f"{BASE_URL}/projects/{slug}/files/move",
|
|
|
|
|
headers=_h_project_files(key),
|
|
|
|
|
data={"from_path": from_path, "to_path": to_path},
|
|
|
|
|
allow_redirects=False,
|
|
|
|
|
)
|
|
|
|
|
def _delete(key, slug, path):
|
|
|
|
|
return requests.post(
|
|
|
|
|
f"{BASE_URL}/projects/{slug}/files/delete",
|
|
|
|
|
headers=_h_project_files(key),
|
|
|
|
|
data={"path": path},
|
|
|
|
|
allow_redirects=False,
|
|
|
|
|
)
|
|
|
|
|
def _list(slug, key=None):
|
|
|
|
|
return requests.get(
|
|
|
|
|
f"{BASE_URL}/projects/{slug}/files",
|
|
|
|
|
headers=_h_project_files(key) if key else {"Accept": "application/json"},
|
|
|
|
|
)
|
|
|
|
|
def _raw_project_files(slug, path, key=None):
|
|
|
|
|
return requests.get(
|
|
|
|
|
f"{BASE_URL}/projects/{slug}/files/raw",
|
|
|
|
|
params={"path": path},
|
|
|
|
|
headers=_h_project_files(key) if key else {"Accept": "application/json"},
|
|
|
|
|
)
|
|
|
|
|
def _paths(slug, key=None):
|
|
|
|
|
return sorted(f["path"] for f in _list(slug, key).json()["files"])
|
|
|
|
|
def _make_project_ui(page, title):
|
|
|
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
|
|
|
page.locator("#create-project-btn").click()
|
|
|
|
|
page.fill("#title", title)
|
|
|
|
|
page.fill("#description", "Project for filesystem UI test")
|
|
|
|
|
page.click("button:has-text('Create Project')")
|
|
|
|
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
|
|
|
|
return page.url
|
|
|
|
|
def _open_files(page, title):
|
|
|
|
|
proj_url = _make_project_ui(page, title)
|
|
|
|
|
slug = proj_url.rstrip("/").split("/")[-1]
|
|
|
|
|
page.goto(proj_url + "/files", wait_until="domcontentloaded")
|
|
|
|
|
return slug
|
|
|
|
|
def _dialog_fill(page, value):
|
|
|
|
|
page.locator(".dialog-overlay.visible .dialog-input").wait_for(state="visible")
|
|
|
|
|
page.fill(".dialog-overlay.visible .dialog-input", value)
|
|
|
|
|
page.click(".dialog-overlay.visible .dialog-confirm")
|
|
|
|
|
def _dialog_confirm(page):
|
|
|
|
|
page.locator(".dialog-overlay.visible .dialog-confirm").wait_for(state="visible")
|
|
|
|
|
page.click(".dialog-overlay.visible .dialog-confirm")
|
|
|
|
|
def _dialog_cancel(page):
|
|
|
|
|
page.locator(".dialog-overlay.visible .dialog-cancel").wait_for(state="visible")
|
|
|
|
|
page.click(".dialog-overlay.visible .dialog-cancel")
|
|
|
|
|
def _new_folder(page, name):
|
|
|
|
|
page.click("#pf-new-folder")
|
|
|
|
|
_dialog_fill(page, name)
|
|
|
|
|
page.wait_for_selector(f".pf-node-row:has-text('{name.split('/')[-1]}')")
|
|
|
|
|
def _new_file(page, name):
|
|
|
|
|
page.click("#pf-new-file")
|
|
|
|
|
_dialog_fill(page, name)
|
|
|
|
|
def _row(page, name):
|
|
|
|
|
return page.locator(f".pf-node-row:has-text('{name}')").first
|
|
|
|
|
def _alice_key():
|
|
|
|
|
return get_table("users").find_one(username="alice_test")["api_key"]
|
2026-06-05 05:36:18 +02:00
|
|
|
def _seed_projects(count):
|
|
|
|
|
owner = str(uuid4())
|
2026-06-09 18:48:08 +02:00
|
|
|
get_table("users").insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": owner,
|
|
|
|
|
"username": f"pag_{owner[:8]}",
|
Add the trust and safety subsystem and the App Store compliance work
Implements the moderation and consent obligations a social platform carries,
so the web version and any client that speaks to it enforce the same rules.
Moderation core (services/moderation/, database/moderation.py): a reportable
target registry, the content filter and its choke points, the report queue with
atomic resolution, enforcement actions, consent tracking, maturity gating, and
account deletion with a grace window.
Surfaces: POST /reports plus the member report list, /admin/moderation and the
per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and
account deletion under /profile, the report button and dialog partials, the
maturity gate, and the moderation stylesheet and ReportDialog client.
Every user-generated surface stays reportable by construction: new content tables
are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a
reason, and the registry test fails the suite on anything left unclassified.
Docs: community guidelines, content moderation, intellectual property, privacy,
terms, contact, and the admin-only moderation operations page, plus the
moderation API group and the Devii moderation actions.
Compliance record: applecomp.md is the requirement register, applechanges.md the
gap analysis against this codebase, and appleimpl.md the implementation design
they resolve to.
Tests cover the report flow, admin moderation, consent, account deletion, terms
acceptance, workspaces, and the registry invariant across the unit, api, and e2e
tiers.
2026-08-09 00:18:20 +02:00
|
|
|
"terms_version": "1",
|
2026-06-09 18:48:08 +02:00
|
|
|
"email": f"{owner[:8]}@test.devplace",
|
|
|
|
|
"password_hash": "x",
|
|
|
|
|
"role": "Member",
|
|
|
|
|
"is_active": True,
|
|
|
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-06-05 05:36:18 +02:00
|
|
|
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
|
|
|
projects = get_table("projects")
|
|
|
|
|
for i in range(count):
|
|
|
|
|
uid = str(uuid4())
|
|
|
|
|
title = f"Pag Project {i}"
|
2026-06-09 18:48:08 +02:00
|
|
|
projects.insert(
|
|
|
|
|
{
|
2026-06-14 16:46:36 +02:00
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
2026-06-09 18:48:08 +02:00
|
|
|
"uid": uid,
|
|
|
|
|
"user_uid": owner,
|
|
|
|
|
"slug": make_combined_slug(title, uid),
|
|
|
|
|
"title": title,
|
|
|
|
|
"description": "paginated",
|
|
|
|
|
"project_type": "software",
|
|
|
|
|
"status": "In Development",
|
|
|
|
|
"stars": 0,
|
|
|
|
|
"created_at": (base - timedelta(seconds=i)).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-06-05 05:36:18 +02:00
|
|
|
return owner
|
2026-06-13 16:32:33 +02:00
|
|
|
def _create_project_projects(page, title):
|
feat: replace raw form parsing with pydantic models for auth, admin, bugs, comments, and gists
Migrate all route handlers from manual `request.form()` parsing to typed `Annotated[Model, Form()]` dependencies using new pydantic models (SignupForm, LoginForm, ForgotPasswordForm, AdminRoleForm, AdminPasswordForm, BugForm, CommentForm, GistForm). Remove inline validation logic from signup, login, password reset, admin role/password change, bug creation, comment creation, and gist creation endpoints, delegating validation to model validators and field constraints. Add `RequestValidationError` exception handler with friendly error messages for auth form pages. Update model definitions to use `field_validator` and `model_validator` for cross-field checks like password matching.
2026-05-23 05:44:04 +02:00
|
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
|
|
|
page.locator("#create-project-btn").click()
|
|
|
|
|
page.fill("#title", title)
|
|
|
|
|
page.fill("#description", "Project for testing")
|
2026-06-09 00:30:25 +02:00
|
|
|
page.fill("#release_date", "01/06/2026")
|
feat: replace raw form parsing with pydantic models for auth, admin, bugs, comments, and gists
Migrate all route handlers from manual `request.form()` parsing to typed `Annotated[Model, Form()]` dependencies using new pydantic models (SignupForm, LoginForm, ForgotPasswordForm, AdminRoleForm, AdminPasswordForm, BugForm, CommentForm, GistForm). Remove inline validation logic from signup, login, password reset, admin role/password change, bug creation, comment creation, and gist creation endpoints, delegating validation to model validators and field constraints. Add `RequestValidationError` exception handler with friendly error messages for auth form pages. Update model definitions to use `field_validator` and `model_validator` for cross-field checks like password matching.
2026-05-23 05:44:04 +02:00
|
|
|
page.check("input[value='game']")
|
|
|
|
|
page.locator("#platforms-input").fill("PC")
|
|
|
|
|
page.locator("#platforms-input").press("Enter")
|
|
|
|
|
page.click("button:has-text('Create Project')")
|
|
|
|
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
2026-06-13 16:32:33 +02:00
|
|
|
def _open_project_with_delete(page, title):
|
|
|
|
|
proj_url = _make_project_ui(page, title)
|
|
|
|
|
slug = proj_url.rstrip("/").split("/")[-1]
|
|
|
|
|
return proj_url, slug
|
|
|
|
|
def _click_delete(page):
|
|
|
|
|
page.locator(".project-actions-more").click()
|
|
|
|
|
page.locator(".context-menu-item:has-text('Delete')").click()
|
|
|
|
|
import asyncio
|
|
|
|
|
import io
|
|
|
|
|
import json
|
|
|
|
|
import zipfile
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from devplacepy.database import get_table, refresh_snapshot
|
|
|
|
|
from devplacepy.services.jobs import queue
|
|
|
|
|
from devplacepy.services.jobs.zip_service import ZipService
|
|
|
|
|
from tests.conftest import run_async
|
|
|
|
|
_counter_zip_download = [0]
|
|
|
|
|
def _signup_zip_download():
|
|
|
|
|
_counter_zip_download[0] += 1
|
|
|
|
|
name = f"zd{int(time.time() * 1000)}{_counter_zip_download[0]}"
|
|
|
|
|
session = requests.Session()
|
|
|
|
|
session.post(
|
|
|
|
|
f"{BASE_URL}/auth/signup",
|
|
|
|
|
data={
|
|
|
|
|
"username": name,
|
|
|
|
|
"email": f"{name}@t.dev",
|
|
|
|
|
"password": "secret123",
|
|
|
|
|
"confirm_password": "secret123",
|
Add the trust and safety subsystem and the App Store compliance work
Implements the moderation and consent obligations a social platform carries,
so the web version and any client that speaks to it enforce the same rules.
Moderation core (services/moderation/, database/moderation.py): a reportable
target registry, the content filter and its choke points, the report queue with
atomic resolution, enforcement actions, consent tracking, maturity gating, and
account deletion with a grace window.
Surfaces: POST /reports plus the member report list, /admin/moderation and the
per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and
account deletion under /profile, the report button and dialog partials, the
maturity gate, and the moderation stylesheet and ReportDialog client.
Every user-generated surface stays reportable by construction: new content tables
are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a
reason, and the registry test fails the suite on anything left unclassified.
Docs: community guidelines, content moderation, intellectual property, privacy,
terms, contact, and the admin-only moderation operations page, plus the
moderation API group and the Devii moderation actions.
Compliance record: applecomp.md is the requirement register, applechanges.md the
gap analysis against this codebase, and appleimpl.md the implementation design
they resolve to.
Tests cover the report flow, admin moderation, consent, account deletion, terms
acceptance, workspaces, and the registry invariant across the unit, api, and e2e
tiers.
2026-08-09 00:18:20 +02:00
|
|
|
"birth_date": "1990-01-01",
|
|
|
|
|
"accept_terms": "1",
|
2026-06-13 16:32:33 +02:00
|
|
|
},
|
|
|
|
|
allow_redirects=True,
|
|
|
|
|
)
|
|
|
|
|
refresh_snapshot()
|
|
|
|
|
key = get_table("users").find_one(username=name)["api_key"]
|
|
|
|
|
return name, key
|
|
|
|
|
def _h_zip_download(key):
|
|
|
|
|
return {"X-API-KEY": key, "Accept": "application/json"}
|
|
|
|
|
def _create_project_zip_download(key, title):
|
|
|
|
|
r = requests.post(
|
|
|
|
|
f"{BASE_URL}/projects/create",
|
|
|
|
|
headers=_h_zip_download(key),
|
|
|
|
|
data={
|
|
|
|
|
"title": title,
|
|
|
|
|
"description": "zip download test",
|
|
|
|
|
"project_type": "software",
|
|
|
|
|
"status": "In Development",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == 200, r.text
|
|
|
|
|
return r.json()["data"]
|
|
|
|
|
def _write_zip_download(key, slug, path, content):
|
|
|
|
|
r = requests.post(
|
|
|
|
|
f"{BASE_URL}/projects/{slug}/files/write",
|
|
|
|
|
headers=_h_zip_download(key),
|
|
|
|
|
data={"path": path, "content": content},
|
|
|
|
|
allow_redirects=False,
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code in (200, 302), r.text
|
|
|
|
|
def _process_uid(uid):
|
|
|
|
|
async def drive():
|
|
|
|
|
svc = ZipService()
|
|
|
|
|
for _ in range(400):
|
|
|
|
|
await svc.run_once()
|
|
|
|
|
refresh_snapshot()
|
|
|
|
|
job = queue.get_job(uid)
|
|
|
|
|
if job and job["status"] in ("done", "failed"):
|
|
|
|
|
return
|
|
|
|
|
await asyncio.sleep(0.05)
|
|
|
|
|
|
|
|
|
|
run_async(drive())
|
|
|
|
|
def _drain_pending():
|
|
|
|
|
async def drive():
|
|
|
|
|
svc = ZipService()
|
|
|
|
|
appeared = False
|
|
|
|
|
for _ in range(600):
|
|
|
|
|
refresh_snapshot()
|
|
|
|
|
pending = [
|
|
|
|
|
r
|
|
|
|
|
for r in get_table("jobs").find(kind="zip")
|
|
|
|
|
if r["status"] in ("pending", "running")
|
|
|
|
|
]
|
|
|
|
|
if pending:
|
|
|
|
|
appeared = True
|
|
|
|
|
await svc.run_once()
|
|
|
|
|
refresh_snapshot()
|
|
|
|
|
pending = [
|
|
|
|
|
r
|
|
|
|
|
for r in get_table("jobs").find(kind="zip")
|
|
|
|
|
if r["status"] in ("pending", "running")
|
|
|
|
|
]
|
|
|
|
|
if appeared and not pending and not svc._inflight:
|
|
|
|
|
return
|
|
|
|
|
await asyncio.sleep(0.05)
|
|
|
|
|
|
|
|
|
|
run_async(drive())
|
|
|
|
|
def _cleanup_archives():
|
|
|
|
|
refresh_snapshot()
|
|
|
|
|
jobs = get_table("jobs")
|
|
|
|
|
for row in list(jobs.find(kind="zip")):
|
|
|
|
|
result = json.loads(row.get("result") or "{}")
|
|
|
|
|
local_path = result.get("local_path")
|
|
|
|
|
if local_path:
|
|
|
|
|
Path(local_path).unlink(missing_ok=True)
|
|
|
|
|
jobs.delete(uid=row["uid"])
|
|
|
|
|
def _login_zip_download(page, name):
|
|
|
|
|
page.goto(f"{BASE_URL}/auth/login", wait_until="domcontentloaded")
|
|
|
|
|
page.fill("#email", f"{name}@t.dev")
|
|
|
|
|
page.fill("#password", "secret123")
|
|
|
|
|
page.click("button:has-text('Sign in')")
|
|
|
|
|
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_feed_topnav_navigation(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
page.click("a:has-text('Projects')")
|
|
|
|
|
page.wait_for_url(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
|
|
|
page.click("a:has-text('Home')")
|
|
|
|
|
page.wait_for_url(f"{BASE_URL}/", wait_until="domcontentloaded")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_auto_open_file_on_load(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
key = _alice_key()
|
|
|
|
|
project = _create_project_project_files(key, "AutoOpen")
|
|
|
|
|
_write_project_files(key, project["slug"], "main.py", "print('hi')")
|
|
|
|
|
page.goto(
|
|
|
|
|
f"{BASE_URL}/projects/{project['slug']}/files", wait_until="domcontentloaded"
|
|
|
|
|
)
|
|
|
|
|
expect(page.locator("#pf-editor-wrap")).to_be_visible()
|
|
|
|
|
expect(page.locator("#pf-empty")).to_be_hidden()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_readme_preferred_on_load(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
key = _alice_key()
|
|
|
|
|
project = _create_project_project_files(key, "ReadmePref")
|
|
|
|
|
_write_project_files(key, project["slug"], "main.py", "x = 1")
|
|
|
|
|
_write_project_files(key, project["slug"], "README.md", "# Hello Readme")
|
|
|
|
|
page.goto(
|
|
|
|
|
f"{BASE_URL}/projects/{project['slug']}/files", wait_until="domcontentloaded"
|
|
|
|
|
)
|
|
|
|
|
page.wait_for_function(
|
|
|
|
|
"window.app && window.app.projectFiles && window.app.projectFiles.editor"
|
|
|
|
|
)
|
|
|
|
|
page.wait_for_timeout(300)
|
|
|
|
|
content = page.evaluate("window.app.projectFiles.editor.getValue()")
|
|
|
|
|
assert "Hello Readme" in content
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_folder_click_keeps_editor(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
key = _alice_key()
|
|
|
|
|
project = _create_project_project_files(key, "FolderKeepsEditor")
|
|
|
|
|
_write_project_files(key, project["slug"], "src/a.py", "x = 1")
|
|
|
|
|
page.goto(
|
|
|
|
|
f"{BASE_URL}/projects/{project['slug']}/files", wait_until="domcontentloaded"
|
|
|
|
|
)
|
|
|
|
|
expect(page.locator("#pf-editor-wrap")).to_be_visible()
|
|
|
|
|
_row(page, "src").click()
|
|
|
|
|
expect(page.locator("#pf-editor-wrap")).to_be_visible()
|
|
|
|
|
expect(page.locator("#pf-empty")).to_be_hidden()
|
feat: replace raw form parsing with pydantic models for auth, admin, bugs, comments, and gists
Migrate all route handlers from manual `request.form()` parsing to typed `Annotated[Model, Form()]` dependencies using new pydantic models (SignupForm, LoginForm, ForgotPasswordForm, AdminRoleForm, AdminPasswordForm, BugForm, CommentForm, GistForm). Remove inline validation logic from signup, login, password reset, admin role/password change, bug creation, comment creation, and gist creation endpoints, delegating validation to model validators and field constraints. Add `RequestValidationError` exception handler with friendly error messages for auth form pages. Update model definitions to use `field_validator` and `model_validator` for cross-field checks like password matching.
2026-05-23 05:44:04 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_project_vote(alice):
|
|
|
|
|
page, _ = alice
|
2026-06-13 16:32:33 +02:00
|
|
|
_create_project_projects(page, "Votable Project")
|
feat: replace raw form parsing with pydantic models for auth, admin, bugs, comments, and gists
Migrate all route handlers from manual `request.form()` parsing to typed `Annotated[Model, Form()]` dependencies using new pydantic models (SignupForm, LoginForm, ForgotPasswordForm, AdminRoleForm, AdminPasswordForm, BugForm, CommentForm, GistForm). Remove inline validation logic from signup, login, password reset, admin role/password change, bug creation, comment creation, and gist creation endpoints, delegating validation to model validators and field constraints. Add `RequestValidationError` exception handler with friendly error messages for auth form pages. Update model definitions to use `field_validator` and `model_validator` for cross-field checks like password matching.
2026-05-23 05:44:04 +02:00
|
|
|
star = "form[action*='/votes/project/'] button"
|
2026-05-27 21:06:18 +02:00
|
|
|
count = "form[action*='/votes/project/'] .vote-count-value"
|
feat: replace raw form parsing with pydantic models for auth, admin, bugs, comments, and gists
Migrate all route handlers from manual `request.form()` parsing to typed `Annotated[Model, Form()]` dependencies using new pydantic models (SignupForm, LoginForm, ForgotPasswordForm, AdminRoleForm, AdminPasswordForm, BugForm, CommentForm, GistForm). Remove inline validation logic from signup, login, password reset, admin role/password change, bug creation, comment creation, and gist creation endpoints, delegating validation to model validators and field constraints. Add `RequestValidationError` exception handler with friendly error messages for auth form pages. Update model definitions to use `field_validator` and `model_validator` for cross-field checks like password matching.
2026-05-23 05:44:04 +02:00
|
|
|
before = int(page.locator(star).first.inner_text().strip("☆ "))
|
|
|
|
|
page.locator(star).first.click()
|
2026-05-27 21:06:18 +02:00
|
|
|
expect(page.locator(count).first).to_have_text(str(before + 1))
|
feat: replace raw form parsing with pydantic models for auth, admin, bugs, comments, and gists
Migrate all route handlers from manual `request.form()` parsing to typed `Annotated[Model, Form()]` dependencies using new pydantic models (SignupForm, LoginForm, ForgotPasswordForm, AdminRoleForm, AdminPasswordForm, BugForm, CommentForm, GistForm). Remove inline validation logic from signup, login, password reset, admin role/password change, bug creation, comment creation, and gist creation endpoints, delegating validation to model validators and field constraints. Add `RequestValidationError` exception handler with friendly error messages for auth form pages. Update model definitions to use `field_validator` and `model_validator` for cross-field checks like password matching.
2026-05-23 05:44:04 +02:00
|
|
|
after = int(page.locator(star).first.inner_text().strip("☆ "))
|
|
|
|
|
assert after == before + 1
|
|
|
|
|
|
|
|
|
|
|
feat: add leaderboard router, gamification backfill, and shared content creation helpers
Introduce a new `/leaderboard` endpoint ranking top 50 members by total stars, wire it into the app router and documentation. Implement `_backfill_gamification()` in `database.py` to compute XP/levels for existing accounts from prior posts, comments, votes, and follows. Extract `is_owner()`, `create_content_item()`, and `detail_context()` into `content.py` to centralize content creation, reward awarding, mention notifications, and attachment linking. Update comment creation/deletion in `comments.py` to use `award_rewards()` with `XP_COMMENT` and the new `is_owner()` helper. Add shared template partials documentation for vote bars, star buttons, post headers, and topic selectors to `AGENTS.md`.
2026-05-30 20:16:39 +02:00
|
|
|
def test_project_voted_state_persists(alice):
|
|
|
|
|
page, _ = alice
|
2026-06-13 16:32:33 +02:00
|
|
|
_create_project_projects(page, "Voted State Project")
|
feat: add leaderboard router, gamification backfill, and shared content creation helpers
Introduce a new `/leaderboard` endpoint ranking top 50 members by total stars, wire it into the app router and documentation. Implement `_backfill_gamification()` in `database.py` to compute XP/levels for existing accounts from prior posts, comments, votes, and follows. Extract `is_owner()`, `create_content_item()`, and `detail_context()` into `content.py` to centralize content creation, reward awarding, mention notifications, and attachment linking. Update comment creation/deletion in `comments.py` to use `award_rewards()` with `XP_COMMENT` and the new `is_owner()` helper. Add shared template partials documentation for vote bars, star buttons, post headers, and topic selectors to `AGENTS.md`.
2026-05-30 20:16:39 +02:00
|
|
|
star = "form[action*='/votes/project/'] button"
|
|
|
|
|
page.locator(star).first.click()
|
|
|
|
|
page.wait_for_timeout(500)
|
|
|
|
|
page.reload(wait_until="domcontentloaded")
|
|
|
|
|
expect(page.locator(star).first).to_have_class(re.compile(r"\bvoted\b"))
|
|
|
|
|
|
|
|
|
|
|
feat: replace raw form parsing with pydantic models for auth, admin, bugs, comments, and gists
Migrate all route handlers from manual `request.form()` parsing to typed `Annotated[Model, Form()]` dependencies using new pydantic models (SignupForm, LoginForm, ForgotPasswordForm, AdminRoleForm, AdminPasswordForm, BugForm, CommentForm, GistForm). Remove inline validation logic from signup, login, password reset, admin role/password change, bug creation, comment creation, and gist creation endpoints, delegating validation to model validators and field constraints. Add `RequestValidationError` exception handler with friendly error messages for auth form pages. Update model definitions to use `field_validator` and `model_validator` for cross-field checks like password matching.
2026-05-23 05:44:04 +02:00
|
|
|
def test_delete_own_project(alice):
|
|
|
|
|
page, _ = alice
|
2026-06-13 16:32:33 +02:00
|
|
|
_create_project_projects(page, "Deletable Project XYZ")
|
feat: replace raw form parsing with pydantic models for auth, admin, bugs, comments, and gists
Migrate all route handlers from manual `request.form()` parsing to typed `Annotated[Model, Form()]` dependencies using new pydantic models (SignupForm, LoginForm, ForgotPasswordForm, AdminRoleForm, AdminPasswordForm, BugForm, CommentForm, GistForm). Remove inline validation logic from signup, login, password reset, admin role/password change, bug creation, comment creation, and gist creation endpoints, delegating validation to model validators and field constraints. Add `RequestValidationError` exception handler with friendly error messages for auth form pages. Update model definitions to use `field_validator` and `model_validator` for cross-field checks like password matching.
2026-05-23 05:44:04 +02:00
|
|
|
proj_url = page.url
|
2026-06-11 00:17:25 +02:00
|
|
|
page.locator(".project-actions-more").click()
|
|
|
|
|
page.locator(".context-menu-item:has-text('Delete')").click()
|
feat: add project file system with CRUD, upload, inline editing, and video attachment support
- Add new `/projects/{slug}/files` endpoint group for per-project filesystem operations including directory and file CRUD, upload, and inline editing with public read and owner write access
- Extend attachment system to support video formats (webm, ogv, mov, m4v) with proper file icons and MIME types
- Implement configurable allowed file types via `allowed_file_types` site setting, replacing hardcoded `ALLOWED_UPLOAD_TYPES` with dynamic `allowed_extensions()` and `is_extension_allowed()` functions
- Add `delete_all_project_files()` call in `delete_content_item()` to clean up project files when a project is deleted
- Create database indexes on `project_files` table for `(project_uid, path)` and `(project_uid, parent_path)` to optimize file lookups
- Introduce `docs_prose.py` module with `render_prose()` function that renders Markdown content inside `data-render` divs using mistune, enabling dynamic prose rendering in documentation pages
- Enhance docs search with Markdown-aware text stripping (`_demarkdown()`) and improved HTML/script/style sanitization for better search indexing
- Update documentation API samples to reflect new attachment response fields (`is_image`, `is_video`, `mime_type`) and note video format support
- Update README to document the new project files endpoint and clarify AI gateway attribution for guest Devii sessions
2026-06-08 22:51:09 +02:00
|
|
|
page.locator(".dialog-overlay.visible .dialog-confirm").click()
|
feat: replace raw form parsing with pydantic models for auth, admin, bugs, comments, and gists
Migrate all route handlers from manual `request.form()` parsing to typed `Annotated[Model, Form()]` dependencies using new pydantic models (SignupForm, LoginForm, ForgotPasswordForm, AdminRoleForm, AdminPasswordForm, BugForm, CommentForm, GistForm). Remove inline validation logic from signup, login, password reset, admin role/password change, bug creation, comment creation, and gist creation endpoints, delegating validation to model validators and field constraints. Add `RequestValidationError` exception handler with friendly error messages for auth form pages. Update model definitions to use `field_validator` and `model_validator` for cross-field checks like password matching.
2026-05-23 05:44:04 +02:00
|
|
|
page.wait_for_url(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
|
|
|
resp = page.goto(proj_url, wait_until="domcontentloaded")
|
|
|
|
|
assert resp.status == 404
|
|
|
|
|
|
|
|
|
|
|
2026-06-11 00:17:25 +02:00
|
|
|
def test_project_edit_button(alice):
|
|
|
|
|
page, _ = alice
|
2026-06-13 16:32:33 +02:00
|
|
|
_create_project_projects(page, "Editable Project ABC")
|
2026-06-11 00:17:25 +02:00
|
|
|
page.locator(".project-actions-more").click()
|
|
|
|
|
expect(page.locator(".context-menu-item:has-text('Edit')")).to_be_visible()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_project_edit_modal_prefills(alice):
|
|
|
|
|
page, _ = alice
|
2026-06-13 16:32:33 +02:00
|
|
|
_create_project_projects(page, "Edit Modal Project")
|
2026-06-11 00:17:25 +02:00
|
|
|
page.locator(".project-actions-more").click()
|
|
|
|
|
page.locator(".context-menu-item:has-text('Edit')").click()
|
|
|
|
|
assert page.is_visible("h3:has-text('Edit Project')")
|
|
|
|
|
expect(page.locator("#edit-project-title")).to_have_value("Edit Modal Project")
|
|
|
|
|
expect(page.locator("#platforms-tags .platform-tag:has-text('PC')")).to_be_visible()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_project_edit_submit(alice):
|
|
|
|
|
page, _ = alice
|
2026-06-13 16:32:33 +02:00
|
|
|
_create_project_projects(page, "Original Project Title")
|
2026-06-11 00:17:25 +02:00
|
|
|
proj_url = page.url
|
|
|
|
|
page.locator(".project-actions-more").click()
|
|
|
|
|
page.locator(".context-menu-item:has-text('Edit')").click()
|
|
|
|
|
page.fill("#edit-project-title", "Edited Project Title")
|
|
|
|
|
page.fill("#edit-project-description", "Edited project description body")
|
|
|
|
|
page.click("button:has-text('Save Changes')")
|
|
|
|
|
expect(
|
|
|
|
|
page.locator(".project-detail-title:has-text('Edited Project Title')")
|
|
|
|
|
).to_be_visible()
|
|
|
|
|
assert page.url.rstrip("/") == proj_url.rstrip("/")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_project_edit_status_change(alice):
|
|
|
|
|
page, _ = alice
|
2026-06-13 16:32:33 +02:00
|
|
|
_create_project_projects(page, "Status Change Project")
|
2026-06-11 00:17:25 +02:00
|
|
|
page.locator(".project-actions-more").click()
|
|
|
|
|
page.locator(".context-menu-item:has-text('Edit')").click()
|
|
|
|
|
page.check("#edit-project-modal input[value='Released']")
|
|
|
|
|
page.click("button:has-text('Save Changes')")
|
|
|
|
|
expect(page.locator(".project-status:has-text('Released')")).to_be_visible()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_project_edit_hidden_for_non_owner(alice, bob):
|
|
|
|
|
page, _ = alice
|
2026-06-13 16:32:33 +02:00
|
|
|
_create_project_projects(page, "Owner Only Edit Project")
|
2026-06-11 00:17:25 +02:00
|
|
|
proj_url = page.url
|
|
|
|
|
bob_page, _ = bob
|
|
|
|
|
bob_page.goto(proj_url, wait_until="domcontentloaded")
|
|
|
|
|
bob_page.locator(".project-actions-more").click()
|
|
|
|
|
expect(
|
|
|
|
|
bob_page.locator(".context-menu-item:has-text('Download zip')")
|
|
|
|
|
).to_be_visible()
|
|
|
|
|
expect(bob_page.locator(".context-menu-item:has-text('Edit')")).to_have_count(0)
|
|
|
|
|
|
|
|
|
|
|
2026-05-23 03:21:55 +02:00
|
|
|
def test_project_detail_share_button(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
|
|
|
page.locator("#create-project-btn").click()
|
|
|
|
|
page.fill("#title", "Share Project")
|
|
|
|
|
page.fill("#description", "A project created for the share button test")
|
2026-06-09 00:30:25 +02:00
|
|
|
page.fill("#release_date", "01/06/2026")
|
2026-05-23 03:21:55 +02:00
|
|
|
page.check("input[value='game']")
|
|
|
|
|
page.locator("#platforms-input").fill("PC")
|
|
|
|
|
page.locator("#platforms-input").press("Enter")
|
|
|
|
|
page.click("button:has-text('Create Project')")
|
|
|
|
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
|
|
|
|
assert_share_copies(page, "/projects/")
|
2026-05-10 09:08:12 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_projects_page_loads(alice):
|
|
|
|
|
page, _ = alice
|
2026-05-11 00:41:41 +02:00
|
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
2026-05-11 05:30:51 +02:00
|
|
|
assert page.is_visible("h1:has-text('Projects')")
|
2026-05-10 09:08:12 +02:00
|
|
|
assert page.is_visible("text=Discover amazing projects")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_projects_search_bar(alice):
|
|
|
|
|
page, _ = alice
|
2026-05-11 00:41:41 +02:00
|
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
2026-05-10 09:08:12 +02:00
|
|
|
search = page.locator("input[placeholder='Search projects...']")
|
|
|
|
|
assert search.is_visible()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_projects_tabs(alice):
|
|
|
|
|
page, _ = alice
|
2026-05-11 00:41:41 +02:00
|
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
2026-05-10 09:08:12 +02:00
|
|
|
tabs = ["Recently Released", "Most Popular", "New This Week"]
|
|
|
|
|
for tab in tabs:
|
|
|
|
|
assert page.is_visible(f"text={tab}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_projects_tab_switch(alice):
|
|
|
|
|
page, _ = alice
|
2026-05-11 00:41:41 +02:00
|
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
2026-05-10 09:08:12 +02:00
|
|
|
page.click("a:has-text('Most Popular')")
|
2026-05-11 00:41:41 +02:00
|
|
|
page.wait_for_url(f"{BASE_URL}/projects?tab=popular", wait_until="domcontentloaded")
|
2026-05-10 09:08:12 +02:00
|
|
|
page.click("a:has-text('New This Week')")
|
2026-05-11 00:41:41 +02:00
|
|
|
page.wait_for_url(f"{BASE_URL}/projects?tab=new", wait_until="domcontentloaded")
|
2026-05-10 09:08:12 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_create_project_button(alice):
|
|
|
|
|
page, _ = alice
|
2026-05-11 00:41:41 +02:00
|
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
2026-05-10 09:08:12 +02:00
|
|
|
fab = page.locator("#create-project-btn")
|
|
|
|
|
assert fab.is_visible()
|
|
|
|
|
fab.click()
|
|
|
|
|
assert page.is_visible("h3:has-text('Create Project')")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_create_project_full(alice):
|
|
|
|
|
page, _ = alice
|
2026-05-11 00:41:41 +02:00
|
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
2026-05-10 09:08:12 +02:00
|
|
|
page.locator("#create-project-btn").click()
|
|
|
|
|
page.fill("#title", "Playwright Test Game")
|
|
|
|
|
page.fill("#description", "A game created by Playwright integration tests")
|
2026-06-09 00:30:25 +02:00
|
|
|
page.fill("#release_date", "01/06/2026")
|
2026-05-10 09:08:12 +02:00
|
|
|
page.check("input[value='game']")
|
|
|
|
|
page.locator("#platforms-input").fill("PC")
|
|
|
|
|
page.locator("#platforms-input").press("Enter")
|
|
|
|
|
page.click("button:has-text('Create Project')")
|
2026-05-11 20:49:45 +02:00
|
|
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
2026-05-10 09:08:12 +02:00
|
|
|
assert page.is_visible("text=Playwright Test Game")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_create_project_software(alice):
|
|
|
|
|
page, _ = alice
|
2026-05-11 00:41:41 +02:00
|
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
2026-05-10 09:08:12 +02:00
|
|
|
page.locator("#create-project-btn").click()
|
|
|
|
|
page.fill("#title", "CLI Tool")
|
|
|
|
|
page.fill("#description", "A command line tool")
|
|
|
|
|
page.check("input[value='software']")
|
|
|
|
|
page.check("input[value='Released']")
|
|
|
|
|
page.click("button:has-text('Create Project')")
|
2026-05-11 20:49:45 +02:00
|
|
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
2026-05-10 09:08:12 +02:00
|
|
|
assert page.is_visible("text=CLI Tool")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_create_project_mobile_app(alice):
|
|
|
|
|
page, _ = alice
|
2026-05-11 00:41:41 +02:00
|
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
2026-05-10 09:08:12 +02:00
|
|
|
page.locator("#create-project-btn").click()
|
|
|
|
|
page.fill("#title", "Mobile Messenger")
|
|
|
|
|
page.fill("#description", "A cross-platform messaging app")
|
|
|
|
|
page.check("input[value='mobile_app']")
|
|
|
|
|
page.locator("#platforms-input").fill("iOS")
|
|
|
|
|
page.locator("#platforms-input").press("Enter")
|
|
|
|
|
page.locator("#platforms-input").fill("Android")
|
|
|
|
|
page.locator("#platforms-input").press("Enter")
|
|
|
|
|
page.click("button:has-text('Create Project')")
|
2026-05-11 20:49:45 +02:00
|
|
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
2026-05-10 09:08:12 +02:00
|
|
|
assert page.is_visible("text=Mobile Messenger")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_project_search(alice):
|
|
|
|
|
page, _ = alice
|
2026-05-11 00:41:41 +02:00
|
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
2026-05-10 09:08:12 +02:00
|
|
|
page.locator("#create-project-btn").click()
|
|
|
|
|
page.fill("#title", "SearchableProject")
|
|
|
|
|
page.fill("#description", "Find me via search")
|
|
|
|
|
page.click("button:has-text('Create Project')")
|
2026-05-11 20:49:45 +02:00
|
|
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
|
|
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
2026-05-10 09:08:12 +02:00
|
|
|
page.fill("input[placeholder='Search projects...']", "SearchableProject")
|
|
|
|
|
page.locator("input[placeholder='Search projects...']").press("Enter")
|
|
|
|
|
page.wait_for_timeout(500)
|
|
|
|
|
assert page.is_visible("text=SearchableProject")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_project_cancel_modal(alice):
|
|
|
|
|
page, _ = alice
|
2026-05-11 00:41:41 +02:00
|
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
2026-05-10 09:08:12 +02:00
|
|
|
page.locator("#create-project-btn").click()
|
|
|
|
|
cancel = page.locator("button:has-text('Cancel')").first
|
|
|
|
|
cancel.click()
|
|
|
|
|
modal = page.locator("#create-project-modal")
|
|
|
|
|
assert not modal.is_visible()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_project_platform_presets(alice):
|
|
|
|
|
page, _ = alice
|
2026-05-11 00:41:41 +02:00
|
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
2026-05-10 09:08:12 +02:00
|
|
|
page.locator("#create-project-btn").click()
|
|
|
|
|
presets = page.locator(".platform-preset")
|
|
|
|
|
assert presets.count() >= 5
|
|
|
|
|
presets.first.click()
|
|
|
|
|
tag = page.locator("#platforms-tags .platform-tag")
|
|
|
|
|
assert tag.is_visible()
|
|
|
|
|
|
|
|
|
|
|
2026-06-05 05:36:18 +02:00
|
|
|
def test_project_pagination_first_page(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
owner = _seed_projects(26)
|
|
|
|
|
page.goto(f"{BASE_URL}/projects?user_uid={owner}", wait_until="domcontentloaded")
|
|
|
|
|
assert page.locator(".project-card").count() == 25
|
|
|
|
|
assert page.is_visible(".load-more-wrap")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_project_pagination_load_more(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
owner = _seed_projects(26)
|
|
|
|
|
page.goto(f"{BASE_URL}/projects?user_uid={owner}", wait_until="domcontentloaded")
|
|
|
|
|
page.click(".load-more-wrap a")
|
|
|
|
|
page.wait_for_url(lambda url: "before=" in url, wait_until="domcontentloaded")
|
|
|
|
|
assert f"user_uid={owner}" in page.url
|
|
|
|
|
assert page.locator(".project-card").count() == 1
|
|
|
|
|
assert not page.is_visible(".load-more-wrap")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_project_pagination_count_reflects_total(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
owner = _seed_projects(26)
|
|
|
|
|
page.goto(f"{BASE_URL}/projects?user_uid={owner}", wait_until="domcontentloaded")
|
|
|
|
|
count_text = page.text_content(".projects-count")
|
|
|
|
|
assert "Showing 25 of 26 projects" in count_text
|
|
|
|
|
|
|
|
|
|
|
2026-05-10 09:08:12 +02:00
|
|
|
def test_projects_count(alice):
|
|
|
|
|
page, _ = alice
|
2026-05-11 00:41:41 +02:00
|
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
2026-05-10 09:08:12 +02:00
|
|
|
count_text = page.text_content(".projects-count")
|
|
|
|
|
assert "Showing" in count_text
|
|
|
|
|
assert "projects" in count_text
|
2026-05-11 07:02:06 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_project_detail_page(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
|
|
|
page.locator("#create-project-btn").click()
|
|
|
|
|
page.fill("#title", "DetailTestProject")
|
|
|
|
|
page.fill("#description", "Project detail page test")
|
|
|
|
|
page.click("button:has-text('Create Project')")
|
|
|
|
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
|
|
|
|
assert page.is_visible("text=DetailTestProject")
|
|
|
|
|
assert page.is_visible("text=Back to Projects")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_project_detail_unauth(page):
|
|
|
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
2026-06-10 05:22:44 +02:00
|
|
|
assert page.is_visible("h1:has-text('Projects')")
|
|
|
|
|
assert page.locator("#create-project-btn").count() == 0
|
2026-05-11 07:02:06 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_project_detail_back_link(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
|
|
|
page.locator("#create-project-btn").click()
|
|
|
|
|
page.fill("#title", "BackLinkProject")
|
|
|
|
|
page.fill("#description", "Test back link")
|
|
|
|
|
page.click("button:has-text('Create Project')")
|
2026-05-11 20:49:45 +02:00
|
|
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
2026-05-11 07:02:06 +02:00
|
|
|
back = page.locator("a:has-text('Back to Projects')")
|
|
|
|
|
assert back.is_visible()
|
|
|
|
|
back.click()
|
|
|
|
|
page.wait_for_url(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
2026-05-11 20:49:45 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_project_comments_form_visible(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
|
|
|
page.locator("#create-project-btn").click()
|
|
|
|
|
page.fill("#title", "CommentProject")
|
|
|
|
|
page.fill("#description", "Project for testing comments")
|
|
|
|
|
page.click("button:has-text('Create Project')")
|
|
|
|
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
|
|
|
|
assert page.is_visible("text=Comments")
|
|
|
|
|
assert page.is_visible("text=No comments yet")
|
|
|
|
|
assert page.is_visible("textarea[name='content']")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_project_comment_create(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
|
|
|
page.locator("#create-project-btn").click()
|
|
|
|
|
page.fill("#title", "CommentCreateProj")
|
|
|
|
|
page.fill("#description", "Project for creating a comment")
|
|
|
|
|
page.click("button:has-text('Create Project')")
|
|
|
|
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
|
|
|
|
page.fill("textarea[name='content']", "Great project!")
|
|
|
|
|
page.click("button:has-text('Post')")
|
|
|
|
|
page.wait_for_timeout(500)
|
|
|
|
|
assert page.is_visible("text=Great project!")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_project_comment_reply(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
|
|
|
page.locator("#create-project-btn").click()
|
|
|
|
|
page.fill("#title", "CommentReplyProj")
|
|
|
|
|
page.fill("#description", "Project for testing reply")
|
|
|
|
|
page.click("button:has-text('Create Project')")
|
|
|
|
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
|
|
|
|
page.fill("textarea[name='content']", "First comment")
|
|
|
|
|
page.click("button:has-text('Post')")
|
|
|
|
|
page.wait_for_timeout(500)
|
|
|
|
|
assert page.is_visible("text=First comment")
|
|
|
|
|
page.click("button:has-text('Reply')")
|
2026-06-05 19:22:29 +02:00
|
|
|
reply_form = page.locator(".comment-reply-form").first
|
|
|
|
|
reply_form.locator("textarea[name='content']").fill("Reply to comment")
|
|
|
|
|
reply_form.locator("button:has-text('Post')").click()
|
2026-05-11 20:49:45 +02:00
|
|
|
page.wait_for_timeout(500)
|
|
|
|
|
assert page.is_visible("text=Reply to comment")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_project_comment_delete(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
|
|
|
page.locator("#create-project-btn").click()
|
|
|
|
|
page.fill("#title", "CommentDeleteProj")
|
|
|
|
|
page.fill("#description", "Project for testing delete")
|
|
|
|
|
page.click("button:has-text('Create Project')")
|
|
|
|
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
|
|
|
|
page.fill("textarea[name='content']", "Comment to delete")
|
|
|
|
|
page.click("button:has-text('Post')")
|
|
|
|
|
page.wait_for_timeout(500)
|
|
|
|
|
assert page.is_visible("text=Comment to delete")
|
|
|
|
|
page.locator(".comment-action-btn:has-text('Delete')").click()
|
feat: add project file system with CRUD, upload, inline editing, and video attachment support
- Add new `/projects/{slug}/files` endpoint group for per-project filesystem operations including directory and file CRUD, upload, and inline editing with public read and owner write access
- Extend attachment system to support video formats (webm, ogv, mov, m4v) with proper file icons and MIME types
- Implement configurable allowed file types via `allowed_file_types` site setting, replacing hardcoded `ALLOWED_UPLOAD_TYPES` with dynamic `allowed_extensions()` and `is_extension_allowed()` functions
- Add `delete_all_project_files()` call in `delete_content_item()` to clean up project files when a project is deleted
- Create database indexes on `project_files` table for `(project_uid, path)` and `(project_uid, parent_path)` to optimize file lookups
- Introduce `docs_prose.py` module with `render_prose()` function that renders Markdown content inside `data-render` divs using mistune, enabling dynamic prose rendering in documentation pages
- Enhance docs search with Markdown-aware text stripping (`_demarkdown()`) and improved HTML/script/style sanitization for better search indexing
- Update documentation API samples to reflect new attachment response fields (`is_image`, `is_video`, `mime_type`) and note video format support
- Update README to document the new project files endpoint and clarify AI gateway attribution for guest Devii sessions
2026-06-08 22:51:09 +02:00
|
|
|
page.locator(".dialog-overlay.visible .dialog-confirm").click()
|
2026-05-11 20:49:45 +02:00
|
|
|
page.wait_for_timeout(500)
|
|
|
|
|
assert not page.is_visible("text=Comment to delete")
|
2026-06-12 06:30:08 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_project_make_private(alice):
|
|
|
|
|
page, _ = alice
|
2026-06-13 16:32:33 +02:00
|
|
|
_create_project_projects(page, "PrivateToggleProject")
|
2026-06-12 06:30:08 +02:00
|
|
|
slug = page.url.rstrip("/").split("/")[-1]
|
2026-06-12 06:55:10 +02:00
|
|
|
page.locator(".project-actions-more").click()
|
|
|
|
|
page.locator(".context-menu-item:has-text('Make private')").click()
|
|
|
|
|
page.locator(".dialog-overlay.visible .dialog-confirm").click()
|
|
|
|
|
page.wait_for_url(f"**/projects/{slug}", wait_until="domcontentloaded")
|
|
|
|
|
expect(page.locator(".badge-type:has-text('Private')")).to_be_visible()
|
2026-06-12 06:30:08 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_project_make_readonly(alice):
|
|
|
|
|
page, _ = alice
|
2026-06-13 16:32:33 +02:00
|
|
|
_create_project_projects(page, "ReadonlyToggleProject")
|
2026-06-12 06:30:08 +02:00
|
|
|
slug = page.url.rstrip("/").split("/")[-1]
|
2026-06-12 06:55:10 +02:00
|
|
|
page.locator(".project-actions-more").click()
|
|
|
|
|
page.locator(".context-menu-item:has-text('Make read-only')").click()
|
|
|
|
|
page.locator(".dialog-overlay.visible .dialog-confirm").click()
|
|
|
|
|
page.wait_for_url(f"**/projects/{slug}", wait_until="domcontentloaded")
|
|
|
|
|
expect(page.locator(".badge-type:has-text('Read-only')")).to_be_visible()
|
2026-06-12 06:30:08 +02:00
|
|
|
|
2026-06-13 16:32:33 +02:00
|
|
|
|
|
|
|
|
def test_data_confirm_accept_proceeds(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
proj_url, _ = _open_project_with_delete(page, "Confirm Accept")
|
|
|
|
|
_click_delete(page)
|
|
|
|
|
page.locator(".dialog-overlay.visible .dialog-confirm").click()
|
|
|
|
|
page.wait_for_url(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
|
|
|
resp = page.goto(proj_url, wait_until="domcontentloaded")
|
|
|
|
|
assert resp.status == 404
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_detail_download_button_downloads(app_server, page):
|
|
|
|
|
try:
|
|
|
|
|
name, key = _signup_zip_download()
|
|
|
|
|
proj = _create_project_zip_download(key, "Detail Download UI")
|
|
|
|
|
slug = proj["slug"] or proj["uid"]
|
|
|
|
|
_write_zip_download(key, slug, "README.md", "# hi")
|
|
|
|
|
_login_zip_download(page, name)
|
|
|
|
|
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
|
|
|
|
|
page.locator(".project-actions-more").click()
|
|
|
|
|
btn = page.locator(".context-menu-item:has-text('Download zip')")
|
|
|
|
|
btn.wait_for(state="visible")
|
|
|
|
|
with page.expect_download(timeout=30000) as dl_info:
|
|
|
|
|
btn.click()
|
|
|
|
|
_drain_pending()
|
|
|
|
|
assert dl_info.value.suggested_filename.endswith(".zip")
|
|
|
|
|
finally:
|
|
|
|
|
_cleanup_archives()
|
feat: add seed helpers for gist, news, and project comment hierarchies in e2e tests
Introduce `_seed_gist_with_comments`, `_seed_news_with_comments`, and `_seed_project_with_comments` helper functions that create a user, a parent entity, and four comments (excluded, flat, parent, reply) with a shared marker prefix and timestamps, enabling consistent comment tree seeding across test modules.
2026-06-16 06:06:16 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _seed_project_with_comments():
|
|
|
|
|
owner = str(uuid4())
|
|
|
|
|
get_table("users").insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": owner,
|
|
|
|
|
"username": f"pcseed_{owner[:8]}",
|
Add the trust and safety subsystem and the App Store compliance work
Implements the moderation and consent obligations a social platform carries,
so the web version and any client that speaks to it enforce the same rules.
Moderation core (services/moderation/, database/moderation.py): a reportable
target registry, the content filter and its choke points, the report queue with
atomic resolution, enforcement actions, consent tracking, maturity gating, and
account deletion with a grace window.
Surfaces: POST /reports plus the member report list, /admin/moderation and the
per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and
account deletion under /profile, the report button and dialog partials, the
maturity gate, and the moderation stylesheet and ReportDialog client.
Every user-generated surface stays reportable by construction: new content tables
are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a
reason, and the registry test fails the suite on anything left unclassified.
Docs: community guidelines, content moderation, intellectual property, privacy,
terms, contact, and the admin-only moderation operations page, plus the
moderation API group and the Devii moderation actions.
Compliance record: applecomp.md is the requirement register, applechanges.md the
gap analysis against this codebase, and appleimpl.md the implementation design
they resolve to.
Tests cover the report flow, admin moderation, consent, account deletion, terms
acceptance, workspaces, and the registry invariant across the unit, api, and e2e
tiers.
2026-08-09 00:18:20 +02:00
|
|
|
"terms_version": "1",
|
feat: add seed helpers for gist, news, and project comment hierarchies in e2e tests
Introduce `_seed_gist_with_comments`, `_seed_news_with_comments`, and `_seed_project_with_comments` helper functions that create a user, a parent entity, and four comments (excluded, flat, parent, reply) with a shared marker prefix and timestamps, enabling consistent comment tree seeding across test modules.
2026-06-16 06:06:16 +02:00
|
|
|
"email": f"{owner[:8]}@pc.seed",
|
|
|
|
|
"password_hash": "x",
|
|
|
|
|
"role": "Member",
|
|
|
|
|
"is_active": True,
|
|
|
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
uid = str(uuid4())
|
|
|
|
|
marker = f"projcom-{uid[:8]}"
|
|
|
|
|
get_table("projects").insert(
|
|
|
|
|
{
|
|
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
|
|
|
|
"uid": uid,
|
|
|
|
|
"user_uid": owner,
|
|
|
|
|
"slug": make_combined_slug(marker, uid),
|
|
|
|
|
"title": marker,
|
|
|
|
|
"description": "Project with comment hierarchy.",
|
|
|
|
|
"project_type": "software",
|
|
|
|
|
"platforms": "Linux",
|
|
|
|
|
"status": "Released",
|
|
|
|
|
"stars": 0,
|
|
|
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
parent = str(uuid4())
|
|
|
|
|
base = datetime.now(timezone.utc)
|
|
|
|
|
rows = [
|
|
|
|
|
(f"{marker}-excluded", str(uuid4()), None, -2),
|
|
|
|
|
(f"{marker}-flat", str(uuid4()), None, -1),
|
|
|
|
|
(f"{marker}-parent", parent, None, 0),
|
|
|
|
|
(f"{marker}-reply", str(uuid4()), parent, 1),
|
|
|
|
|
]
|
|
|
|
|
comments = get_table("comments")
|
|
|
|
|
for content, cuid, par, off in rows:
|
|
|
|
|
comments.insert(
|
|
|
|
|
{
|
|
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
|
|
|
|
"uid": cuid,
|
|
|
|
|
"target_type": "project",
|
|
|
|
|
"target_uid": uid,
|
|
|
|
|
"user_uid": owner,
|
|
|
|
|
"content": content,
|
|
|
|
|
"parent_uid": par,
|
|
|
|
|
"created_at": (base + timedelta(seconds=off)).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return marker
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_projects_list_preserves_comment_hierarchy(page, app_server):
|
|
|
|
|
marker = _seed_project_with_comments()
|
|
|
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
|
|
|
card = page.locator(".project-card").filter(has_text=marker).first
|
|
|
|
|
card.wait_for(state="visible")
|
|
|
|
|
previews = card.locator(".post-card-comments .comment-text")
|
|
|
|
|
expect(previews).to_have_count(3)
|
|
|
|
|
expect(card.locator(".post-card-comments")).not_to_contain_text(f"{marker}-excluded")
|
|
|
|
|
nested = card.locator(".post-card-comments .comment-replies .comment-text")
|
|
|
|
|
expect(nested).to_contain_text(f"{marker}-reply")
|
2026-07-07 15:28:28 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
import time as _time_containers_menu
|
|
|
|
|
import requests as _requests_containers_menu
|
|
|
|
|
from tests.conftest import login_user
|
|
|
|
|
from devplacepy.database import get_primary_admin_uid, invalidate_admins_cache
|
|
|
|
|
from devplacepy.utils import clear_user_cache
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_counter_containers_menu = [0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _signup_containers_menu(prefix):
|
|
|
|
|
_counter_containers_menu[0] += 1
|
|
|
|
|
name = f"{prefix}{int(_time_containers_menu.time() * 1000)}{_counter_containers_menu[0]}"
|
|
|
|
|
_requests_containers_menu.post(
|
|
|
|
|
f"{BASE_URL}/auth/signup",
|
|
|
|
|
data={
|
|
|
|
|
"username": name,
|
|
|
|
|
"email": f"{name}@t.dev",
|
|
|
|
|
"password": "secret123",
|
|
|
|
|
"confirm_password": "secret123",
|
Add the trust and safety subsystem and the App Store compliance work
Implements the moderation and consent obligations a social platform carries,
so the web version and any client that speaks to it enforce the same rules.
Moderation core (services/moderation/, database/moderation.py): a reportable
target registry, the content filter and its choke points, the report queue with
atomic resolution, enforcement actions, consent tracking, maturity gating, and
account deletion with a grace window.
Surfaces: POST /reports plus the member report list, /admin/moderation and the
per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and
account deletion under /profile, the report button and dialog partials, the
maturity gate, and the moderation stylesheet and ReportDialog client.
Every user-generated surface stays reportable by construction: new content tables
are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a
reason, and the registry test fails the suite on anything left unclassified.
Docs: community guidelines, content moderation, intellectual property, privacy,
terms, contact, and the admin-only moderation operations page, plus the
moderation API group and the Devii moderation actions.
Compliance record: applecomp.md is the requirement register, applechanges.md the
gap analysis against this codebase, and appleimpl.md the implementation design
they resolve to.
Tests cover the report flow, admin moderation, consent, account deletion, terms
acceptance, workspaces, and the registry invariant across the unit, api, and e2e
tiers.
2026-08-09 00:18:20 +02:00
|
|
|
"birth_date": "1990-01-01",
|
|
|
|
|
"accept_terms": "1",
|
2026-07-07 15:28:28 +02:00
|
|
|
},
|
|
|
|
|
allow_redirects=True,
|
|
|
|
|
)
|
|
|
|
|
row = get_table("users").find_one(username=name)
|
|
|
|
|
return name, row["uid"], row["api_key"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_admin_containers_menu(prefix):
|
|
|
|
|
name, uid, key = _signup_containers_menu(prefix)
|
|
|
|
|
get_table("users").update({"uid": uid, "role": "Admin"}, ["uid"])
|
|
|
|
|
clear_user_cache(uid)
|
|
|
|
|
invalidate_admins_cache()
|
|
|
|
|
return name, uid, key
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _create_project_containers_menu(key, title, is_private=False):
|
|
|
|
|
data = {
|
|
|
|
|
"title": title,
|
|
|
|
|
"description": "containers menu gating test",
|
|
|
|
|
"project_type": "software",
|
|
|
|
|
"status": "In Development",
|
|
|
|
|
}
|
|
|
|
|
if is_private:
|
|
|
|
|
data["is_private"] = "on"
|
|
|
|
|
r = _requests_containers_menu.post(
|
|
|
|
|
f"{BASE_URL}/projects/create",
|
|
|
|
|
headers={"Accept": "application/json", "X-API-KEY": key},
|
|
|
|
|
data=data,
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == 200, r.text
|
|
|
|
|
return r.json()["data"]["slug"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_containers_menu_visible_for_admin_on_own_private_project(page, app_server):
|
|
|
|
|
name, uid, key = _make_admin_containers_menu("cmown")
|
|
|
|
|
slug = _create_project_containers_menu(key, "Containers Menu Own Private", is_private=True)
|
|
|
|
|
login_user(page, {"email": f"{name}@t.dev", "password": "secret123"})
|
|
|
|
|
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
|
|
|
|
|
page.locator(".project-actions-more").click()
|
|
|
|
|
expect(page.locator(".context-menu-item:has-text('Containers')")).to_be_visible()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_containers_menu_visible_for_any_admin_on_public_project(page, app_server):
|
|
|
|
|
_, _, owner_key = _make_admin_containers_menu("cmpubowner")
|
|
|
|
|
other_name, _, _ = _make_admin_containers_menu("cmpubother")
|
|
|
|
|
slug = _create_project_containers_menu(owner_key, "Containers Menu Public", is_private=False)
|
|
|
|
|
login_user(page, {"email": f"{other_name}@t.dev", "password": "secret123"})
|
|
|
|
|
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
|
|
|
|
|
page.locator(".project-actions-more").click()
|
|
|
|
|
expect(page.locator(".context-menu-item:has-text('Containers')")).to_be_visible()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_containers_menu_hidden_for_non_owner_admin_on_member_private_project(page, app_server):
|
|
|
|
|
_, member_uid, member_key = _signup_containers_menu("cmmember")
|
|
|
|
|
slug = _create_project_containers_menu(member_key, "Containers Menu Member Private", is_private=True)
|
|
|
|
|
admin_name, admin_uid, _ = _make_admin_containers_menu("cmviewer")
|
|
|
|
|
assert get_primary_admin_uid() != admin_uid
|
|
|
|
|
login_user(page, {"email": f"{admin_name}@t.dev", "password": "secret123"})
|
|
|
|
|
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
|
|
|
|
|
page.locator(".project-actions-more").click()
|
|
|
|
|
expect(page.locator(".context-menu-item:has-text('Containers')")).to_have_count(0)
|