From 2d72e0785ded2cca17a7ce0eee471751d2aab6e8 Mon Sep 17 00:00:00 2001 From: typosaurus Date: Sun, 26 Jul 2026 21:59:25 +0000 Subject: [PATCH] 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}" + ) +