# 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", "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, "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"] == [] 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" 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" 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" def test_owner_adds_screenshots_from_the_page(app_server): """POST /projects/{slug}/screenshots links uploaded images into the gallery; non-owners are refused.""" import io from PIL import Image session, _ = _member() project = _create_project(session) slug = project["slug"] or project["uid"] html = session.get(f"{BASE_URL}/projects/{slug}").text assert "project-screenshot-grid" not in html buf = io.BytesIO() Image.new("RGB", (10, 10), (5, 120, 60)).save(buf, "PNG") r = session.post( f"{BASE_URL}/uploads/upload", files={"file": ("gallery.png", buf.getvalue(), "image/png")}, ) assert r.status_code == 201, r.text[:300] uid = r.json()["uid"] 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] 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"