test(sveta): Write tests for devlog timeline
Outcome: done
Changed: tests/api/projects/devlog.py, tests/e2e/projects/devlog.py
Verified by: python3 -m py_compile and pyflakes on both files - passed. Full suite not runnable (Python 3.11 env, project requires >=3.12 - pre-existing).
Findings: tests/api/projects/devlog.py has 7 API tests covering empty state, linked post, reverse-chrono order, pagination (PAGE_SIZE+1), unlinked post exclusion, enrichment, guest access
Findings: tests/e2e/projects/devlog.py has 7 E2E tests covering empty state UI, post title, author info, action buttons, load-more link, guest HTML, newest-first order
Findings: Both files follow existing patterns (alice fixture, expect assertions, uuid4 seeds, wait_until=domcontentloaded)
Open: None
Confidence: high - all acceptance criteria addressed across both test tiers, static analysis clean
Typosaurus-Run: 45c0aee6df2649ffaa248729bdfd5841
Typosaurus-Node: a6579fc028b845c598ed059a20702a91
Typosaurus-Agent: @sveta
Refs: #135
2026-07-26 23:59:25 +02:00
|
|
|
# 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",
|
test(sveta): Write tests for devlog timeline
Outcome: done
Changed: tests/api/projects/devlog.py, tests/e2e/projects/devlog.py
Verified by: python3 -m py_compile and pyflakes on both files - passed. Full suite not runnable (Python 3.11 env, project requires >=3.12 - pre-existing).
Findings: tests/api/projects/devlog.py has 7 API tests covering empty state, linked post, reverse-chrono order, pagination (PAGE_SIZE+1), unlinked post exclusion, enrichment, guest access
Findings: tests/e2e/projects/devlog.py has 7 E2E tests covering empty state UI, post title, author info, action buttons, load-more link, guest HTML, newest-first order
Findings: Both files follow existing patterns (alice fixture, expect assertions, uuid4 seeds, wait_until=domcontentloaded)
Open: None
Confidence: high - all acceptance criteria addressed across both test tiers, static analysis clean
Typosaurus-Run: 45c0aee6df2649ffaa248729bdfd5841
Typosaurus-Node: a6579fc028b845c598ed059a20702a91
Typosaurus-Agent: @sveta
Refs: #135
2026-07-26 23:59:25 +02:00
|
|
|
},
|
|
|
|
|
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(),
|
test(sveta): Write tests for devlog timeline
Outcome: done
Changed: tests/api/projects/devlog.py, tests/e2e/projects/devlog.py
Verified by: python3 -m py_compile and pyflakes on both files - passed. Full suite not runnable (Python 3.11 env, project requires >=3.12 - pre-existing).
Findings: tests/api/projects/devlog.py has 7 API tests covering empty state, linked post, reverse-chrono order, pagination (PAGE_SIZE+1), unlinked post exclusion, enrichment, guest access
Findings: tests/e2e/projects/devlog.py has 7 E2E tests covering empty state UI, post title, author info, action buttons, load-more link, guest HTML, newest-first order
Findings: Both files follow existing patterns (alice fixture, expect assertions, uuid4 seeds, wait_until=domcontentloaded)
Open: None
Confidence: high - all acceptance criteria addressed across both test tiers, static analysis clean
Typosaurus-Run: 45c0aee6df2649ffaa248729bdfd5841
Typosaurus-Node: a6579fc028b845c598ed059a20702a91
Typosaurus-Agent: @sveta
Refs: #135
2026-07-26 23:59:25 +02:00
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
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"] == []
|
|
|
|
|
|
Make the project page an SEO-optimized overview with a devlog environment
The project detail page becomes a professional project overview: hero
with status/type/dates/forked-from, a stats strip (stars, updates,
comments, files, forks with #devlog/#comments anchors), an About
section, platforms, and the Devlog timeline under a proper h2 - now
rendered with feed.css loaded so the post cards are actually styled.
The owner posts updates from the page itself: a Post update button
opens the shared create-post composer preset to the devlog topic and
this project. The composer form is extracted into
_post_composer_form.html and reused by feed.html - one form, two
surfaces.
SEO: software_application_schema is type-aware via project_schema_type
(game -> VideoGame with gamePlatform, website -> WebApplication,
software/mobile_app -> SoftwareApplication, game_asset -> CreativeWork)
and now carries keywords, image, an aggregateRating from stars, and a
comment InteractionCounter. project_devlog_schema emits a Blog node
with one BlogPosting per devlog entry. The detail route feeds both,
adds meta keywords, and exposes the devlog cursor as rel=next; the
sitemap's project lastmod follows the newest devlog post via one
grouped query. devlog_count/comment_count ride ProjectDetailOut, the
docs projects-detail endpoint documents the before cursor, and the
routers/projects CLAUDE.md, templates CLAUDE.md and README document the
new surface. Unit, api and e2e tests cover the schema mapping, the
JSON-LD in the rendered page, the stats strip, and the preset composer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 21:05:04 +02:00
|
|
|
|
|
|
|
|
def test_devlog_count_in_json(app_server):
|
|
|
|
|
"""devlog_count reflects every linked post, beyond the rendered page."""
|
|
|
|
|
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 test_project_page_emits_typed_json_ld(app_server):
|
|
|
|
|
"""A game project renders VideoGame JSON-LD; the devlog renders a Blog graph."""
|
|
|
|
|
session, name = _member()
|
|
|
|
|
title = _unique("dlseo")
|
|
|
|
|
r = session.post(
|
|
|
|
|
f"{BASE_URL}/projects/create",
|
|
|
|
|
headers=JSON,
|
|
|
|
|
data={
|
|
|
|
|
"title": title,
|
|
|
|
|
"description": "SEO schema test project",
|
|
|
|
|
"project_type": "game",
|
|
|
|
|
"status": "In Development",
|
|
|
|
|
"platforms": "PC,Web",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == 200, r.text[:300]
|
|
|
|
|
project = r.json()["data"]
|
|
|
|
|
slug = project["slug"] or project["uid"]
|
|
|
|
|
user = _db_user(name)
|
|
|
|
|
|
|
|
|
|
marker = _unique("dlseopost")
|
|
|
|
|
_create_post_direct(project["uid"], user["uid"], 0, marker=marker)
|
|
|
|
|
|
|
|
|
|
html = session.get(f"{BASE_URL}/projects/{slug}").text
|
|
|
|
|
assert '"VideoGame"' in html, "Expected VideoGame JSON-LD for a game project"
|
|
|
|
|
assert '"Blog"' in html, "Expected a Blog node for the devlog"
|
|
|
|
|
assert '"BlogPosting"' in html, "Expected BlogPosting entries for devlog posts"
|
|
|
|
|
assert '"gamePlatform"' in html, "Expected gamePlatform from the platforms field"
|
|
|
|
|
|
|
|
|
|
|
Dedicate the project page to the project: hero, tabs, screenshots, sidebar
The project detail page becomes a full project showcase on the site's
content measure. The hero card opens with a cover banner from the
project's first image attachment (brand-gradient band as fallback),
then title + status chip, type/platform chips, dates and forked-from
meta, the author row with an owner-set Visit Website CTA, and the
unchanged action row. A sticky anchor tab bar (Overview, Devlog,
Screenshots when images exist, Comments, Files) navigates the page
with plain server-rendered anchors so crawlers index one complete
document. The two-column body keeps About (description + non-image
attachments), the Devlog timeline and the comment thread in the main
column, adds a Screenshots gallery built from image attachments
(lightbox-wired thumbnails), and a sidebar with Links (website, files,
fork source), the Stats card with a last-update line, and the Author
card.
New optional projects.website_url rides the whole stack: normalized
and validated in models (scheme-less input gets https://, non-http(s)
rejected), settable in the create and edit modals, on ProjectOut, in
the Devii create/edit actions and the API docs, rendered as the hero
CTA and Links entry with rel noopener nofollow, and emitted as
schema.org sameAs. The app schema also gains screenshot urls from the
image attachments. The e2e project comment/files tests scope their
locators (.comment-form textarea, .project-detail-actions a) per the
documented dual-control idiom - the composer modal made the bare
selectors ambiguous - and new unit/api tests cover URL normalization,
sameAs/screenshot schema output, the cover, and the gallery.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 21:56:50 +02:00
|
|
|
def test_project_page_cover_and_screenshots_from_image_attachments(app_server):
|
|
|
|
|
"""An image attachment becomes the hero cover, the Screenshots section, and schema screenshots."""
|
|
|
|
|
import io
|
|
|
|
|
from PIL import Image
|
|
|
|
|
|
|
|
|
|
session, _ = _member()
|
|
|
|
|
buf = io.BytesIO()
|
|
|
|
|
Image.new("RGB", (8, 8), (30, 60, 120)).save(buf, "PNG")
|
|
|
|
|
r = session.post(
|
|
|
|
|
f"{BASE_URL}/uploads/upload",
|
|
|
|
|
files={"file": ("shot.png", buf.getvalue(), "image/png")},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == 201, r.text[:300]
|
|
|
|
|
attachment_uid = r.json()["uid"]
|
|
|
|
|
|
|
|
|
|
r = session.post(
|
|
|
|
|
f"{BASE_URL}/projects/create",
|
|
|
|
|
headers=JSON,
|
|
|
|
|
data={
|
|
|
|
|
"title": _unique("dlshot"),
|
|
|
|
|
"description": "Cover test project",
|
|
|
|
|
"project_type": "game",
|
|
|
|
|
"status": "In Development",
|
|
|
|
|
"platforms": "PC",
|
|
|
|
|
"attachment_uids": attachment_uid,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == 200, r.text[:300]
|
|
|
|
|
slug = r.json()["data"]["slug"]
|
|
|
|
|
|
|
|
|
|
html = session.get(f"{BASE_URL}/projects/{slug}").text
|
|
|
|
|
assert 'class="project-cover"' in html, "Expected the image attachment as hero cover"
|
|
|
|
|
assert "project-screenshot-grid" in html, "Expected the Screenshots section"
|
|
|
|
|
assert '"screenshot"' in html, "Expected screenshot urls in the JSON-LD"
|
|
|
|
|
|
|
|
|
|
|
Align the project hero with the showcase design: owner cover, logo, repo link
The hero now matches the showcase reference: the title block, tagline
(first description line), type/platform chips, author row and started
date render OVERLAID on the cover banner behind a bottom scrim, with
the optional project logo as a framed tile beside them and the Visit
Website CTA on the right. The section tab bar switches to the underline
style with Overview active.
Owners control the missing pieces from the create and edit modals,
which are now multipart: cover_image and logo_image file uploads
(stored as bare uploaded filenames via save_inline_image, the
posts.image pattern; a new upload replaces the previous one) plus a
repo_url sibling of website_url (same normalization/validation). The
cover feeds og:image and the schema image, the logo becomes schema
thumbnailUrl, and sameAs now carries website + repository. repo_url
rides ProjectOut, the Devii create/edit actions and the API docs;
tests cover repo normalization, the multipart upload round-trip and
the hero render.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 22:12:25 +02:00
|
|
|
def test_owner_uploaded_cover_and_logo_render_in_hero(app_server):
|
|
|
|
|
"""Multipart cover_image/logo_image uploads land on the row and in the hero."""
|
|
|
|
|
import io
|
|
|
|
|
from PIL import Image
|
|
|
|
|
|
|
|
|
|
session, _ = _member()
|
|
|
|
|
|
|
|
|
|
def png(color):
|
|
|
|
|
buf = io.BytesIO()
|
|
|
|
|
Image.new("RGB", (12, 6), color).save(buf, "PNG")
|
|
|
|
|
return buf.getvalue()
|
|
|
|
|
|
|
|
|
|
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",
|
|
|
|
|
},
|
|
|
|
|
files={
|
|
|
|
|
"cover_image": ("cover.png", png((10, 20, 90)), "image/png"),
|
|
|
|
|
"logo_image": ("logo.png", png((90, 20, 10)), "image/png"),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
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_image"], "cover_image filename expected on the row"
|
|
|
|
|
assert row["logo_image"], "logo_image filename expected on the row"
|
|
|
|
|
|
|
|
|
|
html = session.get(f"{BASE_URL}/projects/{slug}").text
|
|
|
|
|
assert f"/static/uploads/{row['cover_image']}" in html
|
|
|
|
|
assert f"/static/uploads/{row['logo_image']}" in html
|
|
|
|
|
assert 'class="project-logo"' in html
|
|
|
|
|
assert '"thumbnailUrl"' in html, "Expected the logo as schema thumbnailUrl"
|
|
|
|
|
|
|
|
|
|
|
Make the project page an SEO-optimized overview with a devlog environment
The project detail page becomes a professional project overview: hero
with status/type/dates/forked-from, a stats strip (stars, updates,
comments, files, forks with #devlog/#comments anchors), an About
section, platforms, and the Devlog timeline under a proper h2 - now
rendered with feed.css loaded so the post cards are actually styled.
The owner posts updates from the page itself: a Post update button
opens the shared create-post composer preset to the devlog topic and
this project. The composer form is extracted into
_post_composer_form.html and reused by feed.html - one form, two
surfaces.
SEO: software_application_schema is type-aware via project_schema_type
(game -> VideoGame with gamePlatform, website -> WebApplication,
software/mobile_app -> SoftwareApplication, game_asset -> CreativeWork)
and now carries keywords, image, an aggregateRating from stars, and a
comment InteractionCounter. project_devlog_schema emits a Blog node
with one BlogPosting per devlog entry. The detail route feeds both,
adds meta keywords, and exposes the devlog cursor as rel=next; the
sitemap's project lastmod follows the newest devlog post via one
grouped query. devlog_count/comment_count ride ProjectDetailOut, the
docs projects-detail endpoint documents the before cursor, and the
routers/projects CLAUDE.md, templates CLAUDE.md and README document the
new surface. Unit, api and e2e tests cover the schema mapping, the
JSON-LD in the rendered page, the stats strip, and the preset composer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 21:05:04 +02:00
|
|
|
def test_project_page_json_ld_rating_from_stars(app_server):
|
|
|
|
|
"""Stars surface as an aggregateRating; zero stars emit none."""
|
|
|
|
|
session, _ = _member()
|
|
|
|
|
project = _create_project(session)
|
|
|
|
|
slug = project["slug"] or project["uid"]
|
|
|
|
|
|
|
|
|
|
html = session.get(f"{BASE_URL}/projects/{slug}").text
|
|
|
|
|
assert '"aggregateRating"' not in html, "No rating expected without stars"
|
|
|
|
|
|
|
|
|
|
r = session.post(
|
|
|
|
|
f"{BASE_URL}/votes/project/{project['uid']}",
|
|
|
|
|
data={"value": "1"},
|
|
|
|
|
headers={"X-Requested-With": "fetch"},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == 200, r.text[:300]
|
|
|
|
|
|
|
|
|
|
html = session.get(f"{BASE_URL}/projects/{slug}").text
|
|
|
|
|
assert '"aggregateRating"' in html, "Expected aggregateRating once starred"
|
|
|
|
|
|