395 lines
12 KiB
Python
Raw Normal View History

# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone, timedelta
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid
JSON = {"Accept": "application/json"}
_counter = [0]
@pytest.fixture(scope="module", autouse=True)
def _devlog_test_settings(app_server):
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
}.items():
set_setting(key, value)
yield
def _unique(prefix="dl"):
_counter[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter[0]}"
def _member():
name = _unique("dlmem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
Add the trust and safety subsystem and the App Store compliance work Implements the moderation and consent obligations a social platform carries, so the web version and any client that speaks to it enforce the same rules. Moderation core (services/moderation/, database/moderation.py): a reportable target registry, the content filter and its choke points, the report queue with atomic resolution, enforcement actions, consent tracking, maturity gating, and account deletion with a grace window. Surfaces: POST /reports plus the member report list, /admin/moderation and the per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and account deletion under /profile, the report button and dialog partials, the maturity gate, and the moderation stylesheet and ReportDialog client. Every user-generated surface stays reportable by construction: new content tables are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a reason, and the registry test fails the suite on anything left unclassified. Docs: community guidelines, content moderation, intellectual property, privacy, terms, contact, and the admin-only moderation operations page, plus the moderation API group and the Devii moderation actions. Compliance record: applecomp.md is the requirement register, applechanges.md the gap analysis against this codebase, and appleimpl.md the implementation design they resolve to. Tests cover the report flow, admin moderation, consent, account deletion, terms acceptance, workspaces, and the registry invariant across the unit, api, and e2e tiers.
2026-08-09 00:18:20 +02:00
"birth_date": "1990-01-01",
"accept_terms": "1",
},
allow_redirects=True,
)
return s, name
def _db_user(name):
refresh_snapshot()
return get_table("users").find_one(username=name)
def _create_project(session, title=None):
title = title or _unique("dlproj")
r = session.post(
f"{BASE_URL}/projects/create",
headers=JSON,
data={
"title": title,
"description": "Devlog test project",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
)
assert r.status_code == 200, r.text[:300]
return r.json()["data"]
def _create_post(session, content, project_uid, title=None):
title = title or _unique("dlpost")
r = session.post(
f"{BASE_URL}/posts/create",
headers=JSON,
data={
"title": title,
"content": content,
"topic": "devlog",
"project_uid": project_uid,
},
)
assert r.status_code == 200, r.text[:300]
return r.json()["data"]
def _create_post_direct(project_uid, user_uid, order, marker=None):
"""Insert a post directly into DB with precise created_at ordering."""
uid = generate_uid()
marker = marker or f"dlpost-{uid[:8]}"
get_table("posts").insert(
{
"deleted_at": None,
"deleted_by": None,
"uid": uid,
"user_uid": user_uid,
"slug": f"{uid[:8]}-devlog-post",
"title": marker,
"content": f"Devlog post content {order}",
"topic": "devlog",
"project_uid": project_uid,
"image": None,
"stars": 0,
Fix circular import, primary-admin NULL trap, and add gateway quota reset Restores a working import graph and closes two data-correctness bugs, plus adds a reset for the AI gateway's rolling 24h spend. Circular import: database/__init__ -> engagement -> content -> utils -> database made the package unimportable. get_project_devlog moves out of database/engagement.py into content.py, where enrich_items already lives. Primary administrator: _can_hold_primary_admin read is_active with bool(row.get("is_active")), so an admin row whose is_active column is SQL NULL (any row predating the column) was treated as deactivated and skipped. Every other site defaults an unknown is_active to active; this one now does too. Profile JSON: xp_next_level and xp_progress_pct were computed but only put on the top-level context, never on profile_user, so they serialised as null even though UserOut declares them and the API docs document them as embedded there. Gateway quota reset: a cap previously lifted only with the passage of time. quota.reset upserts a watermark row into gateway_quota_resets, scoped by the same three nullable dimensions as a quota rule, and spent_24h sums from max(24h cutoff, watermark). No ledger row is deleted, so the cost analytics on /admin/ai-usage stay intact. Reaches every surface: POST /admin/gateway/quota-resets, a per-rule Reset spend button, the Devii tool gateway_quota_reset (confirm-gated), devplace gateway quota reset, and the API docs. Admin's Reset all quotas now stamps a global gateway watermark too, which is what a caller stuck on "AI gateway daily quota exceeded" needed. Startup: _backfill_gamification swept every xp=0 user on every boot in every worker and could never converge, since a user with no content earns no XP. It now intersects pending users with _milestone_candidates(). db.tables is a live reflection, so it is hoisted out of the loops that probed it per row. Docker: the dependency layer now depends on pyproject.toml only, so a source edit no longer reinstalls every dependency and re-downloads Chromium. Adds start_interval so the healthcheck probes during the start period, and a docker-reload target, since docker-up does not restart an unchanged container. Adds events.md, the audit event catalogue that README, CLAUDE.md, the quiz docs and the tooling all referenced but which never existed: 288 keys across 28 categories, including the families built from a variable at the call site. Test fixes: both devlog helpers dated post 0 as the newest while the tests assumed post 2 was; a profile login posted username= to a form that takes email=; a devlog assertion matched six buttons under strict mode; and the primary-admin tests seeded founders newer than the back-dated fixture admin, so they only passed without the api tier. Full suite: 2989 passed, 1 skipped.
2026-07-27 11:17:48 +02:00
"created_at": (datetime(2026, 1, 1, tzinfo=timezone.utc) + timedelta(seconds=order)).isoformat(),
}
)
refresh_snapshot()
return uid, marker
def test_devlog_empty_state_when_no_posts(app_server):
"""Project with no linked posts returns empty devlog_posts list."""
session, _ = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
assert body["devlog_posts"] == []
assert body["devlog_next_cursor"] is None
def test_devlog_shows_linked_post(app_server):
"""A post linked via project_uid appears in the project's devlog."""
session, name = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
marker = _unique("dllink")
_create_post(session, marker, project["uid"], title=marker)
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
assert len(body["devlog_posts"]) == 1
assert body["devlog_next_cursor"] is None
post_item = body["devlog_posts"][0]
assert post_item["post"]["title"] == marker
assert post_item["author"]["username"] == name
def test_devlog_reverse_chronological_order(app_server):
"""Multiple linked posts appear newest-first."""
session, name = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
user = _db_user(name)
project_uid = project["uid"]
markers = []
for i in range(3):
marker = f"dlorder-{i}-{generate_uid()[:8]}"
markers.append(marker)
_create_post_direct(project_uid, user["uid"], i, marker=marker)
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
titles = [item["post"]["title"] for item in body["devlog_posts"]]
assert titles == list(reversed(markers)), (
f"Expected newest-first order: {list(reversed(markers))}, got: {titles}"
)
def test_devlog_pagination(app_server):
"""More than PAGE_SIZE posts produce next_cursor."""
from devplacepy.database.pagination import PAGE_SIZE
session, name = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
user = _db_user(name)
project_uid = project["uid"]
count = PAGE_SIZE + 1
for i in range(count):
_create_post_direct(project_uid, user["uid"], i, marker=f"dlpag-{i}")
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
assert len(body["devlog_posts"]) == PAGE_SIZE, (
f"Expected {PAGE_SIZE} posts on first page, got {len(body['devlog_posts'])}"
)
assert body["devlog_next_cursor"] is not None, (
"Expected next_cursor when more than PAGE_SIZE posts exist"
)
before = body["devlog_next_cursor"]
r2 = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON, params={"before": before})
assert r2.status_code == 200, r2.text[:300]
body2 = r2.json()
assert len(body2["devlog_posts"]) == 1, (
f"Expected 1 post on second page, got {len(body2['devlog_posts'])}"
)
assert body2["devlog_next_cursor"] is None, (
"Expected no next_cursor on last page"
)
r_html = session.get(f"{BASE_URL}/projects/{slug}")
assert r_html.status_code == 200
assert 'class="load-more-wrap"' in r_html.text, (
"Expected Load More button in HTML for paginated devlog"
)
def test_devlog_excludes_unlinked_posts(app_server):
"""Posts without project_uid do not appear in any project's devlog."""
session, _ = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
unlinked = _unique("dlnolink")
session.post(
f"{BASE_URL}/posts/create",
headers=JSON,
data={
"title": unlinked,
"content": "This post has no project",
"topic": "devlog",
},
)
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
titles = [item["post"]["title"] for item in body["devlog_posts"]]
assert unlinked not in titles, (
"Post without project_uid must not appear in devlog"
)
def test_devlog_enriches_author_and_metadata(app_server):
"""Devlog posts include author data, comment count, and vote info."""
session, name = _member()
user = _db_user(name)
project = _create_project(session)
slug = project["slug"] or project["uid"]
marker = _unique("dlenrich")
post_data = _create_post(session, marker, project["uid"], title=marker)
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
assert len(body["devlog_posts"]) == 1
item = body["devlog_posts"][0]
assert item["author"]["username"] == name
assert item["author"]["uid"] == user["uid"]
assert isinstance(item["my_vote"], int)
assert isinstance(item["comment_count"], int)
assert item["comment_count"] == 0
assert item["post"]["uid"] == post_data["uid"]
assert item["post"]["slug"] == post_data["slug"]
assert item["post"]["title"] == marker
assert item["time_ago"] is not None
def test_devlog_works_for_guest_visitor(app_server):
"""Unauthenticated visitors can see the devlog section."""
session, _ = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
r = requests.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
assert "devlog_posts" in body
assert body["devlog_posts"] == []
Dedicate the project page to the project The project detail page becomes a full project showcase built entirely from existing platform mechanisms. One encompassing dark card wraps the page; inner panels (tab bar, sidebar cards, devlog entries, comments) sit one elevation lighter. The hero opens with a cover banner and an optional logo tile, both plain attachment references (cover_attachment_uid/logo_attachment_uid) uploaded through the standard dp-upload attachment widget and linked via the existing link_attachments choke point - the route validates each uid belongs to the actor and is an image, and an empty value on edit keeps the current one. The title block, type/platform chips and author row overlay the banner behind a scrim with a dark text shadow, next to an owner-set Visit Website CTA; website_url and repo_url are normalized in models and render with rel noopener nofollow. An anchor tab bar (Overview, Devlog, Screenshots when present, Comments, Files) navigates the page. The main column keeps About, the devlog timeline (with devlog_count and an owner Post update button opening the shared composer preset to the devlog topic + project - the form now lives once in _post_composer_form.html, included by feed.html and project_detail.html), a Screenshots gallery built from image attachments minus the cover/logo (thumbnails, lightbox, 12 rendered), and the comment thread; the sidebar holds Links, Stats and the Author card. Owners add gallery images from the More menu via POST /projects/{slug}/screenshots (owner-only, audit project.screenshots.add, Devii action project_add_screenshots, docs id projects-screenshots). comment_count/devlog_count ride ProjectDetailOut, the new fields ride ProjectOut, and the create/edit faces (modals, Devii actions, API docs) carry them. The project comment/files e2e tests scope their locators per the documented dual-control idiom, and new unit/api/e2e tests cover URL normalization, the counts, the hero attachment guard, the screenshots flow and the preset composer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 23:00:12 +02:00
def test_devlog_count_and_comment_count_in_json(app_server):
"""devlog_count and comment_count ride the detail JSON."""
session, name = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
user = _db_user(name)
for i in range(3):
_create_post_direct(project["uid"], user["uid"], i)
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
assert body["devlog_count"] == 3
assert body["comment_count"] == 0
def _upload_image(session, name="shot.png", color=(30, 60, 120)):
import io
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", (10, 10), color).save(buf, "PNG")
r = session.post(
f"{BASE_URL}/uploads/upload",
files={"file": (name, buf.getvalue(), "image/png")},
)
assert r.status_code == 201, r.text[:300]
return r.json()["uid"]
def test_cover_and_logo_attachments_render_in_hero(app_server):
"""cover/logo attachment uids resolve to the hero banner and logo tile."""
session, _ = _member()
cover_uid = _upload_image(session, "cover.png", (10, 20, 90))
logo_uid = _upload_image(session, "logo.png", (90, 20, 10))
r = session.post(
f"{BASE_URL}/projects/create",
headers=JSON,
data={
"title": _unique("dlhero"),
"description": "Hero art test project",
"project_type": "game",
"status": "In Development",
"platforms": "PC",
"cover_attachment_uid": cover_uid,
"logo_attachment_uid": logo_uid,
},
)
assert r.status_code == 200, r.text[:300]
slug = r.json()["data"]["slug"]
refresh_snapshot()
row = get_table("projects").find_one(slug=slug)
assert row["cover_attachment_uid"] == cover_uid
assert row["logo_attachment_uid"] == logo_uid
html = session.get(f"{BASE_URL}/projects/{slug}").text
assert 'class="project-cover-img"' in html
assert 'class="project-logo"' in html
assert "project-screenshot-grid" not in html, (
"Cover and logo must not repeat in the Screenshots gallery"
)
def test_hero_attachment_uid_rejects_foreign_and_missing(app_server):
"""A foreign or unknown attachment uid is ignored rather than linked."""
owner, _ = _member()
other, _ = _member()
foreign_uid = _upload_image(other, "foreign.png")
r = owner.post(
f"{BASE_URL}/projects/create",
headers=JSON,
data={
"title": _unique("dlreject"),
"description": "Hero guard test project",
"project_type": "software",
"status": "In Development",
"platforms": "",
"cover_attachment_uid": foreign_uid,
"logo_attachment_uid": "does-not-exist",
},
)
assert r.status_code == 200, r.text[:300]
slug = r.json()["data"]["slug"]
refresh_snapshot()
row = get_table("projects").find_one(slug=slug)
assert row["cover_attachment_uid"] is None
assert row["logo_attachment_uid"] is None
def test_owner_adds_screenshots_from_the_page(app_server):
"""POST /projects/{slug}/screenshots links uploads into the gallery; non-owners are refused."""
session, _ = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
assert "project-screenshot-grid" not in session.get(f"{BASE_URL}/projects/{slug}").text
uid = _upload_image(session, "gallery.png", (5, 120, 60))
r = session.post(
f"{BASE_URL}/projects/{slug}/screenshots",
headers=JSON,
data={"attachment_uids": uid},
)
assert r.status_code == 200, r.text[:300]
assert r.json()["data"]["linked"] == 1
html = session.get(f"{BASE_URL}/projects/{slug}").text
assert "project-screenshot-grid" in html, "Expected the gallery after linking"
intruder, _ = _member()
r = intruder.post(
f"{BASE_URL}/projects/{slug}/screenshots",
headers=JSON,
data={"attachment_uids": uid},
)
assert r.status_code == 403, r.text[:300]