Compare commits
8 Commits
b2c0342748
...
adb5def4da
| Author | SHA1 | Date | |
|---|---|---|---|
| adb5def4da | |||
| cbd3f697fd | |||
| d14bb4c671 | |||
| e05f97c924 | |||
| 2d72e0785d | |||
| dbe1e2670b | |||
| fda72c5afb | |||
| 6b3df26a52 |
@ -360,6 +360,29 @@ def create_comment_record(
|
||||
comment_url,
|
||||
)
|
||||
|
||||
# Notify previous commenters on this post (participation)
|
||||
posts = get_table("posts")
|
||||
post = posts.find_one(uid=target_uid)
|
||||
if not post:
|
||||
post = posts.find_one(slug=target_uid)
|
||||
if post:
|
||||
post_owner_uid = post["user_uid"]
|
||||
previous_commenters = set()
|
||||
for c in get_table("comments").find(
|
||||
target_type="post", target_uid=target_uid, deleted_at=None
|
||||
):
|
||||
cu = c["user_uid"]
|
||||
if cu != user["uid"] and cu != post_owner_uid:
|
||||
previous_commenters.add(cu)
|
||||
for cu in previous_commenters:
|
||||
create_notification(
|
||||
cu,
|
||||
"participation",
|
||||
f"{user['username']} also commented on this post",
|
||||
user["uid"],
|
||||
comment_url,
|
||||
)
|
||||
|
||||
create_mention_notifications(content, user["uid"], comment_url)
|
||||
schedule_correction(user, "comments", comment_uid, request)
|
||||
schedule_modification(user, "comments", comment_uid, request)
|
||||
@ -718,3 +741,4 @@ def enrich_items(
|
||||
)
|
||||
enriched.append(entry)
|
||||
return enriched
|
||||
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ NOTIFICATION_TYPES = [
|
||||
{"key": "vote", "label": "Upvotes", "description": "Someone ++'d your content"},
|
||||
{"key": "follow", "label": "Followers", "description": "Someone starts following you"},
|
||||
{"key": "message", "label": "Direct messages", "description": "Someone sends you a message"},
|
||||
{"key": "participation", "label": "Post participation", "description": "Someone else comments on a post you also commented on"},
|
||||
{"key": "badge", "label": "Badges", "description": "You earn a badge"},
|
||||
{"key": "level", "label": "Level-ups", "description": "You reach a new level"},
|
||||
{"key": "issue", "label": "Issue tracker", "description": "Updates on issue reports you filed"},
|
||||
@ -199,3 +200,4 @@ def mark_notifications_read_by_target(user_uid: str, target_url: str) -> int:
|
||||
|
||||
clear_unread_cache(user_uid)
|
||||
return len(ids)
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -425,7 +425,7 @@ four ways to sign requests.
|
||||
method="POST",
|
||||
path="/profile/{username}/notifications",
|
||||
title="Toggle a notification preference",
|
||||
summary="Enable or disable one notification type on one channel (in-app or push). Admins may target any user. Types: comment, reply, mention, vote, follow, message, badge, level, issue, reminder, harvest_stolen.",
|
||||
summary="Enable or disable one notification type on one channel (in-app or push). Admins may target any user. Types: comment, reply, mention, vote, follow, message, participation, badge, level, issue, reminder, harvest_stolen.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
@ -444,7 +444,7 @@ four ways to sign requests.
|
||||
"string",
|
||||
True,
|
||||
"vote",
|
||||
"One of: comment, reply, mention, vote, follow, message, badge, level, issue, reminder, harvest_stolen.",
|
||||
"One of: comment, reply, mention, vote, follow, message, participation, badge, level, issue, reminder, harvest_stolen.",
|
||||
),
|
||||
field(
|
||||
"channel",
|
||||
@ -793,3 +793,5 @@ four ways to sign requests.
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -102,6 +102,18 @@
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<section class="project-devlog">
|
||||
<h3 class="project-section-label">Devlog</h3>
|
||||
{% 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 %}
|
||||
{% set next_cursor = devlog_next_cursor %}{% include "_load_more.html" %}
|
||||
{% else %}
|
||||
<p class="empty-state">No devlog posts yet.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if is_owner %}
|
||||
{% call modal('edit-project-modal', 'Edit Project') %}
|
||||
<form method="POST" action="/projects/edit/{{ project['slug'] or project['uid'] }}">
|
||||
@ -184,3 +196,6 @@ if (actions) {
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
|
||||
263
tests/api/comments/notifications.py
Normal file
263
tests/api/comments/notifications.py
Normal file
@ -0,0 +1,263 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import time
|
||||
import pytest
|
||||
import requests
|
||||
from tests.conftest import BASE_URL
|
||||
from devplacepy.database import get_table, refresh_snapshot, set_setting
|
||||
|
||||
_COUNTER = [0]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def _participation_settings(app_server):
|
||||
for key, value in {
|
||||
"rate_limit_per_minute": "1000000",
|
||||
"rate_limit_window_seconds": "60",
|
||||
"registration_open": "1",
|
||||
"maintenance_mode": "0",
|
||||
"max_upload_size_mb": "10",
|
||||
"allowed_file_types": "",
|
||||
"max_attachments_per_resource": "10",
|
||||
"session_max_age_days": "7",
|
||||
"session_remember_days": "30",
|
||||
"news_service_interval": "3600",
|
||||
"news_grade_threshold": "7",
|
||||
}.items():
|
||||
set_setting(key, value)
|
||||
yield
|
||||
|
||||
|
||||
def _db_user(username):
|
||||
refresh_snapshot()
|
||||
return get_table("users").find_one(username=username)
|
||||
|
||||
|
||||
def _unique(prefix="pn"):
|
||||
_COUNTER[0] += 1
|
||||
return f"{prefix}{int(time.time() * 1000)}{_COUNTER[0]}"
|
||||
|
||||
|
||||
def _signup():
|
||||
name = _unique("pnuser")
|
||||
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 _create_post(session):
|
||||
r = session.post(
|
||||
f"{BASE_URL}/posts/create",
|
||||
data={
|
||||
"title": _unique("pnpost"),
|
||||
"content": "Post for participation notification test.",
|
||||
"topic": "devlog",
|
||||
},
|
||||
allow_redirects=False,
|
||||
)
|
||||
slug = r.headers["location"].split("/posts/")[-1]
|
||||
refresh_snapshot()
|
||||
return get_table("posts").find_one(slug=slug)["uid"]
|
||||
|
||||
|
||||
def _comment_on_post(session, post_uid, content):
|
||||
r = session.post(
|
||||
f"{BASE_URL}/comments/create",
|
||||
data={
|
||||
"content": content,
|
||||
"target_type": "post",
|
||||
"post_uid": post_uid,
|
||||
"target_uid": post_uid,
|
||||
},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert r.status_code in (302, 303), (
|
||||
f"Comment creation failed: {r.status_code} {r.text[:300]}"
|
||||
)
|
||||
|
||||
|
||||
def _reply_to_comment(session, post_uid, parent_uid, content):
|
||||
r = session.post(
|
||||
f"{BASE_URL}/comments/create",
|
||||
data={
|
||||
"content": content,
|
||||
"target_type": "post",
|
||||
"post_uid": post_uid,
|
||||
"target_uid": post_uid,
|
||||
"parent_uid": parent_uid,
|
||||
},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert r.status_code in (302, 303), (
|
||||
f"Reply creation failed: {r.status_code} {r.text[:300]}"
|
||||
)
|
||||
|
||||
|
||||
def _find_comment(post_uid, content):
|
||||
refresh_snapshot()
|
||||
return get_table("comments").find_one(target_uid=post_uid, content=content)
|
||||
|
||||
|
||||
def _notifications_for(user_uid):
|
||||
refresh_snapshot()
|
||||
return list(
|
||||
get_table("notifications").find(
|
||||
user_uid=user_uid, order_by=["-created_at"]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _participation_notifications_for(user_uid):
|
||||
refresh_snapshot()
|
||||
return list(
|
||||
get_table("notifications").find(
|
||||
user_uid=user_uid, type="participation", order_by=["-created_at"]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_participation_notification_sent(app_server):
|
||||
"""User A receives a participation notification when User B comments
|
||||
on a post that User A previously commented on."""
|
||||
owner_session, owner_name = _signup()
|
||||
post_uid = _create_post(owner_session)
|
||||
|
||||
a_session, a_name = _signup()
|
||||
b_session, b_name = _signup()
|
||||
|
||||
_comment_on_post(a_session, post_uid, "User A first comment")
|
||||
_comment_on_post(b_session, post_uid, "User B comment")
|
||||
|
||||
a_user = _db_user(a_name)
|
||||
assert a_user is not None
|
||||
|
||||
participation_notifs = _participation_notifications_for(a_user["uid"])
|
||||
assert len(participation_notifs) >= 1, (
|
||||
f"User {a_name} should have at least one participation notification, "
|
||||
f"got {len(participation_notifs)}"
|
||||
)
|
||||
latest = participation_notifs[0]
|
||||
assert latest["type"] == "participation"
|
||||
assert b_name in latest["message"], (
|
||||
f"Expected notification message to contain {b_name!r}, "
|
||||
f"got {latest['message']!r}"
|
||||
)
|
||||
assert "also commented" in latest["message"], (
|
||||
f"Expected 'also commented' in message, got {latest['message']!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_post_owner_no_participation_duplicate(app_server):
|
||||
"""Post owner does NOT receive a participation notification.
|
||||
They already receive the 'comment' notification."""
|
||||
owner_session, owner_name = _signup()
|
||||
post_uid = _create_post(owner_session)
|
||||
|
||||
a_session, a_name = _signup()
|
||||
b_session, b_name = _signup()
|
||||
|
||||
_comment_on_post(a_session, post_uid, "User A comment for owner test")
|
||||
_comment_on_post(b_session, post_uid, "User B comment for owner test")
|
||||
|
||||
owner = _db_user(owner_name)
|
||||
assert owner is not None
|
||||
|
||||
participation_notifs = _participation_notifications_for(owner["uid"])
|
||||
assert len(participation_notifs) == 0, (
|
||||
f"Post owner {owner_name} should have zero participation notifications, "
|
||||
f"got {len(participation_notifs)}: "
|
||||
f"{[n['message'] for n in participation_notifs]}"
|
||||
)
|
||||
|
||||
all_notifs = _notifications_for(owner["uid"])
|
||||
comment_notifs = [n for n in all_notifs if n["type"] == "comment"]
|
||||
assert len(comment_notifs) >= 1, (
|
||||
f"Post owner should have at least one 'comment' notification, "
|
||||
f"got {len(comment_notifs)}"
|
||||
)
|
||||
|
||||
|
||||
def test_commenter_no_self_notification(app_server):
|
||||
"""Commenter does NOT receive a participation notification
|
||||
for their own comment."""
|
||||
owner_session, _ = _signup()
|
||||
post_uid = _create_post(owner_session)
|
||||
|
||||
a_session, a_name = _signup()
|
||||
_comment_on_post(a_session, post_uid, "User A self-test comment")
|
||||
|
||||
b_session, b_name = _signup()
|
||||
_comment_on_post(b_session, post_uid, "User B self-test comment")
|
||||
|
||||
b_user = _db_user(b_name)
|
||||
assert b_user is not None
|
||||
|
||||
participation_notifs = _participation_notifications_for(b_user["uid"])
|
||||
self_notifs = [
|
||||
n for n in participation_notifs if b_name in n["message"]
|
||||
]
|
||||
assert len(self_notifs) == 0, (
|
||||
f"User {b_name} should not have a participation notification "
|
||||
f"about themselves, got {len(self_notifs)}"
|
||||
)
|
||||
|
||||
|
||||
def test_reply_triggers_participation(app_server):
|
||||
"""A reply to a comment triggers participation notifications
|
||||
for other previous commenters (excluding the reply author and post owner).
|
||||
The implementation sends participation for every comment on a post,
|
||||
both top-level and replies."""
|
||||
owner_session, owner_name = _signup()
|
||||
post_uid = _create_post(owner_session)
|
||||
|
||||
a_session, a_name = _signup()
|
||||
_comment_on_post(a_session, post_uid, "User A top-level comment")
|
||||
a_comment = _find_comment(post_uid, "User A top-level comment")
|
||||
|
||||
c_session, c_name = _signup()
|
||||
_comment_on_post(c_session, post_uid, "User C third participant comment")
|
||||
|
||||
b_session, b_name = _signup()
|
||||
_reply_to_comment(b_session, post_uid, a_comment["uid"], "User B reply")
|
||||
|
||||
a_user = _db_user(a_name)
|
||||
c_user = _db_user(c_name)
|
||||
|
||||
a_participation = _participation_notifications_for(a_user["uid"])
|
||||
c_participation = _participation_notifications_for(c_user["uid"])
|
||||
|
||||
a_has_participation = any(
|
||||
b_name in n["message"] for n in a_participation
|
||||
)
|
||||
c_has_participation = any(
|
||||
b_name in n["message"] for n in c_participation
|
||||
)
|
||||
|
||||
assert a_has_participation, (
|
||||
f"User {a_name} (parent commenter) should receive a participation "
|
||||
f"notification when a reply is posted on the same post. "
|
||||
f"Notifications: {[n['message'] for n in a_participation]}"
|
||||
)
|
||||
assert c_has_participation, (
|
||||
f"User {c_name} (previous commenter) should receive a participation "
|
||||
f"notification when a reply is posted on the same post. "
|
||||
f"Notifications: {[n['message'] for n in c_participation]}"
|
||||
)
|
||||
|
||||
b_user = _db_user(b_name)
|
||||
b_participation = _participation_notifications_for(b_user["uid"])
|
||||
b_self = [n for n in b_participation if b_name in n["message"]]
|
||||
assert len(b_self) == 0, (
|
||||
f"Reply author {b_name} should not receive a participation "
|
||||
f"notification about themselves."
|
||||
)
|
||||
|
||||
271
tests/api/projects/devlog.py
Normal file
271
tests/api/projects/devlog.py
Normal file
@ -0,0 +1,271 @@
|
||||
# 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"] == []
|
||||
|
||||
186
tests/e2e/projects/devlog.py
Normal file
186
tests/e2e/projects/devlog.py
Normal file
@ -0,0 +1,186 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
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}"
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user