From 6b3df26a52e3f447310e39aeb4b8726eb8bd70c0 Mon Sep 17 00:00:00 2001 From: typosaurus Date: Sun, 26 Jul 2026 20:50:56 +0000 Subject: [PATCH 1/4] feat(nadia): Add database index and devlog query helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outcome: done Changed: devplacepy/database/schema.py:45, devplacepy/database/engagement.py:3-5,194-210, devplacepy/database/__init__.py:11,118 Verified by: `python3 -m py_compile` on all three files — passed; `pyflakes` on engagement.py — clean (no warnings) Findings: Index idx_posts_project_uid added to init_db at devplacepy/database/schema.py:45 (CREATE INDEX IF NOT EXISTS on posts.project_uid) Findings: get_project_devlog(project_uid: str, before: str | None = None, viewer: dict | None = None) -> tuple[list, str | None] defined at devplacepy/database/engagement.py:194, returns (list of enriched post dicts, next_cursor) using paginate() with project_uid filter, auto-filtered for deleted_at IS NULL, batch helpers get_users_by_uids and get_comment_counts_by_post_uids, and enrich_items() for post enrichment including author, time_ago, my_vote, comment_count Findings: get_project_devlog exported from devplacepy/database/__init__.py via import (line 11) and __all__ (line 118) Open: None Confidence: high - all acceptance criteria met, syntax verified, no warnings introduced Typosaurus-Run: 45c0aee6df2649ffaa248729bdfd5841 Typosaurus-Node: 17f01a1e325e40ecb73ffe423f78c4a9 Typosaurus-Agent: @nadia Refs: #135 --- devplacepy/database/__init__.py | 5 ++++- devplacepy/database/engagement.py | 27 ++++++++++++++++++++++++++- devplacepy/database/schema.py | 2 ++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/devplacepy/database/__init__.py b/devplacepy/database/__init__.py index 7ba8b5f5..f24cc8ea 100644 --- a/devplacepy/database/__init__.py +++ b/devplacepy/database/__init__.py @@ -8,7 +8,7 @@ from .users import get_users_by_uids, _admins_cache, invalidate_admins_cache, ge from .relations import _relations_cache, get_user_relations, get_blocked_uids, get_muted_uids, get_silenced_uids, invalidate_user_relations from .pagination import PAGE_SIZE, paginate, interleave_by_author, paginate_diverse, get_user_post_count, clear_user_post_count, build_pagination from .soft_delete import SOFT_DELETE_TABLES, ensure_soft_delete_columns, soft_delete, soft_delete_in, restore, purge, list_deleted, count_deleted, restore_event, purge_event -from .engagement import _comment_count_cache, get_comment_counts_by_post_uids, get_post_counts_by_user_uids, get_vote_counts, get_user_votes, get_reactions_by_targets, get_user_bookmarks, get_polls_by_post_uids, get_poll_for_post +from .engagement import _comment_count_cache, get_comment_counts_by_post_uids, get_post_counts_by_user_uids, get_project_devlog, get_vote_counts, get_user_votes, get_reactions_by_targets, get_user_bookmarks, get_polls_by_post_uids, get_poll_for_post from .usage import _add_usage, _get_usage, add_correction_usage, get_correction_usage, add_modifier_usage, get_modifier_usage, NEWS_USAGE_KEY, add_news_usage, get_news_usage, ISSUE_USAGE_KEY, add_issue_usage, get_issue_usage, SEO_USAGE_KEY, add_seo_usage, get_seo_usage, AWARD_USAGE_KEY, add_award_usage, get_award_usage from .awards import ( AWARDS_PER_PAGE, @@ -115,6 +115,7 @@ __all__ = [ "_comment_count_cache", "get_comment_counts_by_post_uids", "get_post_counts_by_user_uids", + "get_project_devlog", "get_vote_counts", "get_user_votes", "get_reactions_by_targets", @@ -254,3 +255,5 @@ __all__ = [ "backfill_api_keys", "_backfill_gamification", ] + + diff --git a/devplacepy/database/engagement.py b/devplacepy/database/engagement.py index 1d0b16f0..ccb93a42 100644 --- a/devplacepy/database/engagement.py +++ b/devplacepy/database/engagement.py @@ -1,6 +1,9 @@ # retoor -from .core import TTLCache, _in_clause, db, defaultdict +from .core import TTLCache, _in_clause, db, defaultdict, get_table +from .pagination import paginate +from devplacepy.content import enrich_items +from .users import get_users_by_uids _comment_count_cache = TTLCache(ttl=15, max_size=10000) @@ -185,3 +188,25 @@ def get_polls_by_post_uids(post_uids, user=None): def get_poll_for_post(post_uid, user=None): return get_polls_by_post_uids([post_uid], user).get(post_uid) + + +def get_project_devlog(project_uid: str, before: str | None = None, viewer: dict | None = None) -> tuple[list, str | None]: + posts_table = get_table("posts") + posts, next_cursor = paginate( + posts_table, + before=before, + viewer_uid=viewer["uid"] if viewer else None, + project_uid=project_uid, + ) + if not posts: + return [], None + + authors = get_users_by_uids([p["user_uid"] for p in posts]) + counts = get_comment_counts_by_post_uids([p["uid"] for p in posts]) + + result = enrich_items( + posts, "post", authors, {"comment_count": counts}, user=viewer + ) + return result, next_cursor + + diff --git a/devplacepy/database/schema.py b/devplacepy/database/schema.py index 48da4f59..22c06a14 100644 --- a/devplacepy/database/schema.py +++ b/devplacepy/database/schema.py @@ -42,6 +42,7 @@ def init_db(): _index(db, "posts", "idx_posts_created_at", ["created_at"]) _index(db, "posts", "idx_posts_topic", ["topic"]) _index(db, "posts", "idx_posts_slug", ["slug"]) + _index(db, "posts", "idx_posts_project_uid", ["project_uid"]) if "posts" in tables: posts_table = get_table("posts") if not posts_table.has_column("tags"): @@ -1826,3 +1827,4 @@ def _backfill_gamification(): for user in pending: check_milestone_badges(user["uid"]) logger.info(f"Gamification backfill processed {len(pending)} users") + -- 2.45.2 From fda72c5afb99db6df9e5e4967b5d78d58f58c485 Mon Sep 17 00:00:00 2001 From: typosaurus Date: Sun, 26 Jul 2026 21:10:15 +0000 Subject: [PATCH 2/4] feat(nadia): Add devlog route logic and schema update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outcome: done Changed: devplacepy/routers/projects/index.py:9-10,17-20,178-241, devplacepy/schemas/listings.py:166-167, devplacepy/templates/project_detail.html:105-119 Verified by: `make test` not runnable — Python 3.11 environment cannot install `dataset` (requires >=3.12). Pre-existing limitation, not change-specific. Static checks (`py_compile`, `pyflakes`) pass clean on all changed files. Findings: - devplacepy/routers/projects/index.py:178 — `project_detail()` accepts optional `before: str = None` query param. - devplacepy/routers/projects/index.py:225-241 — Devlog posts fetched via `get_project_devlog()`, enriched with attachments/reactions/bookmarks/polls matching feed.py. - devplacepy/schemas/listings.py:166-167 — `ProjectDetailOut` gains `devlog_posts: list[FeedItemOut]` and `devlog_next_cursor: Optional[str]`. - devplacepy/templates/project_detail.html:105-119 — Devlog section reuses `_post_card.html`, shows Load More pagination when cursor present, empty-state message when none. Open: none Confidence: high - criteria addressed, static analysis clean, pattern matches existing code. Typosaurus-Run: 45c0aee6df2649ffaa248729bdfd5841 Typosaurus-Node: 8c955653b126445bac9cc5496369bd7e Typosaurus-Agent: @nadia Refs: #135 --- devplacepy/routers/projects/index.py | 30 +++++++++++++++++++++++- devplacepy/schemas/listings.py | 3 +++ devplacepy/templates/project_detail.html | 18 ++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/devplacepy/routers/projects/index.py b/devplacepy/routers/projects/index.py index aac1fe21..ad434b25 100644 --- a/devplacepy/routers/projects/index.py +++ b/devplacepy/routers/projects/index.py @@ -6,6 +6,7 @@ from sqlalchemy import or_ from fastapi import Depends, APIRouter, Request from devplacepy.models import ProjectForm, ProjectEditForm, ProjectFlagForm, ForkForm from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse +from devplacepy.attachments import get_attachments_batch from devplacepy.database import ( get_table, get_users_by_uids, @@ -13,6 +14,10 @@ from devplacepy.database import ( get_site_stats, get_user_votes, get_recent_comments_by_target_uids, + get_reactions_by_targets, + get_user_bookmarks, + get_polls_by_post_uids, + get_project_devlog, paginate, text_search_clause, resolve_by_slug, @@ -171,7 +176,7 @@ async def projects_page( ) @router.get("/{project_slug}", response_class=HTMLResponse) -async def project_detail(request: Request, project_slug: str): +async def project_detail(request: Request, project_slug: str, before: str = None): user = get_current_user(request) detail = load_detail("projects", "project", project_slug, user) if not detail: @@ -216,6 +221,25 @@ async def project_detail(request: Request, project_slug: str): if parent else None ) + + devlog_posts, devlog_next_cursor = get_project_devlog( + project["uid"], before=before, viewer=user + ) + if devlog_posts: + post_uids = [item["post"]["uid"] for item in devlog_posts] + attachments_map = get_attachments_batch("post", post_uids) + reactions_map = get_reactions_by_targets("post", post_uids, user) + bookmark_set = ( + get_user_bookmarks(user["uid"], "post", post_uids) if user else set() + ) + polls_map = get_polls_by_post_uids(post_uids, user) + for item in devlog_posts: + uid = item["post"]["uid"] + item["attachments"] = attachments_map.get(uid, []) + item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []}) + item["bookmarked"] = uid in bookmark_set + item["poll"] = polls_map.get(uid) + return respond( request, "project_detail.html", @@ -235,6 +259,8 @@ async def project_detail(request: Request, project_slug: str): "forked_from": forked_from, "fork_count": count_forks(project["uid"]), "file_count": count_files(project["uid"]), + "devlog_posts": devlog_posts, + "devlog_next_cursor": devlog_next_cursor, }, ), model=ProjectDetailOut, @@ -464,3 +490,5 @@ async def set_project_readonly( request: Request, project_slug: str, data: Annotated[ProjectFlagForm, Depends(json_or_form(ProjectFlagForm))] ): return _set_project_flag(request, project_slug, "read_only", data.value) + + diff --git a/devplacepy/schemas/listings.py b/devplacepy/schemas/listings.py index b3b7f2a7..ecbab0b7 100644 --- a/devplacepy/schemas/listings.py +++ b/devplacepy/schemas/listings.py @@ -163,6 +163,8 @@ class ProjectDetailOut(_Out): forked_from: Optional[dict] = None fork_count: int = 0 file_count: int = 0 + devlog_posts: list[FeedItemOut] = [] + devlog_next_cursor: Optional[str] = None class GistsOut(_Out): @@ -232,3 +234,4 @@ class LeaderboardOut(_Out): class SavedOut(_Out): items: list[SavedItemOut] = [] next_cursor: Optional[str] = None + diff --git a/devplacepy/templates/project_detail.html b/devplacepy/templates/project_detail.html index 2b57ad19..df14fe10 100644 --- a/devplacepy/templates/project_detail.html +++ b/devplacepy/templates/project_detail.html @@ -102,6 +102,22 @@ +
+ + {% if devlog_posts %} + {% for item in devlog_posts %} + {% set _author = item.author %}{% set _time = item.time_ago %}{% set _show_share = false %}{% set _show_comment_form = false %}{% include "_post_card.html" %} + {% endfor %} + {% if devlog_next_cursor %} + + {% endif %} + {% else %} +

No devlog posts yet.

+ {% endif %} +
+ {% if is_owner %} {% call modal('edit-project-modal', 'Edit Project') %}
@@ -184,3 +200,5 @@ if (actions) { } {% endblock %} + + -- 2.45.2 From dbe1e2670b944c800201a41d08fd46911ba205ce Mon Sep 17 00:00:00 2001 From: typosaurus Date: Sun, 26 Jul 2026 21:20:07 +0000 Subject: [PATCH 3/4] feat(nadia): Add devlog section to project detail template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ```yaml Outcome: done Changed: devplacepy/templates/project_detail.html:116-128, devplacepy/static/css/projects.css:308-310 Verified by: `python3` inline script — ALL CHECKS PASSED (includes resolve, Jinja2 blocks balanced, devlog section present with all required context vars, CSS braces balanced, CSS classes exist) Findings: - Template project_detail.html:116-127 — Devlog section renders posts via `_post_card.html` with context variables (`_author`, `_time`, `_show_share=false`, `_show_comment_form=false`). - Template project_detail.html:123 — Pagination reuses `_load_more.html` via `{% set next_cursor = devlog_next_cursor %}{% include "_load_more.html" %}`. - Template project_detail.html:125 — Empty state displayed as `

No devlog posts yet.

` when `devlog_posts` is falsy. - CSS projects.css:308-310 — `.project-devlog` rule provides `margin-top: 1.5rem` separation from project detail article. - CSS projects.css:312-319 — `.project-section-label` rule (existing) reused for devlog heading styling. - No new CSS file reference added — `projects.css` and `post.css` were already loaded in the template `extra_head`. - All Jinja2 block tags balanced (3 block/endblock, 21 if/endif, 4 for/endfor, 1 call/endcall, 1 with/endwith). - All 9 `{% include %}` paths resolve to existing template files. - Inline styles from previous attempt removed — pagination now uses `_load_more.html` exclusively. Open: none Confidence: high — all 6 acceptance crit Typosaurus-Run: 45c0aee6df2649ffaa248729bdfd5841 Typosaurus-Node: 7bfee38d7ded4822a374ef066bcf85aa Typosaurus-Agent: @nadia Refs: #135 --- devplacepy/static/css/projects.css | 5 +++++ devplacepy/templates/project_detail.html | 7 ++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/devplacepy/static/css/projects.css b/devplacepy/static/css/projects.css index 409942ad..aa4cab89 100644 --- a/devplacepy/static/css/projects.css +++ b/devplacepy/static/css/projects.css @@ -305,6 +305,10 @@ margin-bottom: 1.5rem; } +.project-devlog { + margin-top: 1.5rem; +} + .project-section-label { font-size: 0.75rem; font-weight: 700; @@ -313,3 +317,4 @@ color: var(--text-muted); margin-bottom: 0.5rem; } + diff --git a/devplacepy/templates/project_detail.html b/devplacepy/templates/project_detail.html index df14fe10..93770cf5 100644 --- a/devplacepy/templates/project_detail.html +++ b/devplacepy/templates/project_detail.html @@ -108,11 +108,7 @@ {% for item in devlog_posts %} {% set _author = item.author %}{% set _time = item.time_ago %}{% set _show_share = false %}{% set _show_comment_form = false %}{% include "_post_card.html" %} {% endfor %} - {% if devlog_next_cursor %} - - {% endif %} + {% set next_cursor = devlog_next_cursor %}{% include "_load_more.html" %} {% else %}

No devlog posts yet.

{% endif %} @@ -202,3 +198,4 @@ if (actions) { {% endblock %} + -- 2.45.2 From 2d72e0785ded2cca17a7ce0eee471751d2aab6e8 Mon Sep 17 00:00:00 2001 From: typosaurus Date: Sun, 26 Jul 2026 21:59:25 +0000 Subject: [PATCH 4/4] 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 --- tests/api/projects/devlog.py | 271 +++++++++++++++++++++++++++++++++++ tests/e2e/projects/devlog.py | 186 ++++++++++++++++++++++++ 2 files changed, 457 insertions(+) create mode 100644 tests/api/projects/devlog.py create mode 100644 tests/e2e/projects/devlog.py diff --git a/tests/api/projects/devlog.py b/tests/api/projects/devlog.py new file mode 100644 index 00000000..0cfedfcd --- /dev/null +++ b/tests/api/projects/devlog.py @@ -0,0 +1,271 @@ +# retoor + +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", + }, + 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, + "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"] == [] + diff --git a/tests/e2e/projects/devlog.py b/tests/e2e/projects/devlog.py new file mode 100644 index 00000000..b2e627c0 --- /dev/null +++ b/tests/e2e/projects/devlog.py @@ -0,0 +1,186 @@ +# retoor + +from uuid import uuid4 +from datetime import datetime, timezone, timedelta +from playwright.sync_api import expect +from tests.conftest import BASE_URL +from devplacepy.database import get_table +from devplacepy.utils import make_combined_slug + + +def _seed_project(): + """Create a project seeded directly into DB and return (slug, uid, user_uid).""" + owner = str(uuid4()) + get_table("users").insert( + { + "uid": owner, + "username": f"dlowner_{owner[:8]}", + "email": f"{owner[:8]}@dl.test", + "password_hash": "x", + "role": "Member", + "is_active": True, + "created_at": datetime.now(timezone.utc).isoformat(), + } + ) + uid = str(uuid4()) + slug = make_combined_slug("E2E Devlog Project", uid) + get_table("projects").insert( + { + "deleted_at": None, + "deleted_by": None, + "uid": uid, + "user_uid": owner, + "slug": slug, + "title": "E2E Devlog Project", + "description": "Project for devlog browser tests.", + "project_type": "software", + "platforms": "", + "status": "In Development", + "stars": 0, + "created_at": datetime.now(timezone.utc).isoformat(), + } + ) + return slug, uid, owner + + +def _seed_project_post(project_uid, user_uid, order, title=None): + """Insert a post linked to a project with precise ordering.""" + uid = str(uuid4()) + marker = title or f"dlpost-{uid[:8]}" + get_table("posts").insert( + { + "deleted_at": None, + "deleted_by": None, + "uid": uid, + "user_uid": user_uid, + "slug": make_combined_slug(marker, uid), + "title": marker, + "content": f"Devlog post content {order}", + "topic": "devlog", + "project_uid": project_uid, + "image": None, + "stars": 0, + "created_at": (datetime(2026, 1, 1, tzinfo=timezone.utc) - timedelta(seconds=order)).isoformat(), + } + ) + return marker, uid + + +def _create_project_ui(page, title): + """Create a project via the UI.""" + page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded") + page.locator("#create-project-btn").click() + page.fill("#title", title) + page.fill("#description", "Project for devlog UI test") + page.click("button:has-text('Create Project')") + page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded") + return page.url + + +def test_devlog_empty_state_on_project_page(alice): + """Project with no linked posts displays 'No devlog posts yet'.""" + page, _ = alice + _create_project_ui(page, "Empty Devlog Project") + + devlog_section = page.locator(".project-devlog") + expect(devlog_section).to_be_visible() + expect(devlog_section.locator("h3:has-text('Devlog')")).to_be_visible() + expect(page.locator(".empty-state:has-text('No devlog posts yet.')")).to_be_visible() + + +def test_devlog_shows_linked_post_title(alice): + """Linked post title renders in the devlog section.""" + page, _ = alice + slug, project_uid, owner_uid = _seed_project() + + marker = f"visible-dl-{uuid4().hex[:8]}" + _seed_project_post(project_uid, owner_uid, 0, title=marker) + + page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded") + + devlog = page.locator(".project-devlog") + expect(devlog.locator(f"h3:has-text('{marker}')")).to_be_visible() + + +def test_devlog_shows_author_info(alice): + """Author avatar, name and level appear on devlog posts.""" + page, _ = alice + slug, project_uid, owner_uid = _seed_project() + + marker = f"auth-dl-{uuid4().hex[:8]}" + _seed_project_post(project_uid, owner_uid, 0, title=marker) + + page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded") + + post_card = page.locator(".project-devlog .post-card").first + expect(post_card.locator(".post-header")).to_be_visible() + expect(post_card.locator(".post-author-link")).to_be_visible() + expect(post_card.locator(".post-time")).to_be_visible() + + +def test_devlog_shows_vote_and_comment_buttons(alice): + """Devlog post renders vote buttons and comment count.""" + page, _ = alice + slug, project_uid, owner_uid = _seed_project() + + marker = f"action-dl-{uuid4().hex[:8]}" + _seed_project_post(project_uid, owner_uid, 0, title=marker) + + page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded") + + post_card = page.locator(".project-devlog .post-card").first + expect(post_card.locator(".post-action-btn")).to_be_visible() + expect(post_card.locator("form[action*='/posts/delete/']")).to_be_visible() + + +def test_devlog_load_more_appears_with_many_posts(alice): + """More than PAGE_SIZE posts produces a Load More link.""" + from devplacepy.database.pagination import PAGE_SIZE + + page, _ = alice + slug, project_uid, owner_uid = _seed_project() + + count = PAGE_SIZE + 1 + for i in range(count): + _seed_project_post(project_uid, owner_uid, i, title=f"loadmore-{i}") + + page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded") + + devlog = page.locator(".project-devlog") + expect(devlog.locator(".post-card")).to_have_count(PAGE_SIZE) + expect(devlog.locator(".load-more-wrap")).to_be_visible() + + +def test_devlog_guest_sees_devlog_section(app_server): + """Unauthenticated visitors can see the devlog section.""" + import requests + + slug, _, _ = _seed_project() + + r = requests.get(f"{BASE_URL}/projects/{slug}") + assert r.status_code == 200, r.text[:200] + + assert "No devlog posts yet." in r.text + assert 'class="project-devlog"' in r.text + + +def test_devlog_multiple_posts_order(alice): + """Posts appear newest-first in the devlog.""" + page, _ = alice + slug, project_uid, owner_uid = _seed_project() + + markers = [] + for i in range(3): + marker = f"order-{i}-{uuid4().hex[:8]}" + markers.append(marker) + _seed_project_post(project_uid, owner_uid, i, title=marker) + + page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded") + + titles = page.locator(".project-devlog .post-card .post-title") + expect(titles).to_have_count(3) + first_text = titles.nth(0).inner_text() + assert markers[-1] == first_text, ( + f"Expected newest post first: {markers[-1]}, got: {first_text}" + ) + -- 2.45.2