272 lines
8.4 KiB
Python
272 lines
8.4 KiB
Python
|
|
# 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",
|
||
|
|
},
|
||
|
|
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"] == []
|
||
|
|
|