Compare commits

..
Author SHA1 Message Date
typosaurus 8d5d5f90be test(sveta): Write API test verifying badge names in profile JSON response
DevPlace CI / test (pull_request) Failing after 7m41s
Outcome: done
Changed: tests/api/profile/search.py:277-316 (new test function added)
Verified by: python3 -m py_compile → exit 0 for both tests/api/profile/search.py and devplacepy/schemas/content.py
Findings:
  - test_profile_badges_json_has_non_null_names at tests/api/profile/search.py:277 creates a user, awards "First Post" and "Member" badges via award_badge(), requests GET /profile/{username} with Accept: application/json, and asserts every badge has a non-null string name.
  - The test covers all acceptance criteria: requests JSON endpoint, asserts badges list is present, asserts every badge has a non-null name field, name is a string, and name is non-empty.
  - BadgeOut.name at devplacepy/schemas/content.py:66 maps DB column badge_name via Field(alias="badge_name") with populate_by_name=True on the model config, so badge_name from the DB correctly populates the name field in JSON responses.
  - Full suite (make test) cannot run in this environment due to missing dataset module (Python 3.11.2, pre-existing limitation).
  - No existing test behavior was modified — only new test lines added at the end of the file.
Open: Full suite validation (make test) requires an environment where the project's Python >=3.12 dependency is satisfied and dataset is installed.
Confidence: high — test structurally correct, compiles cleanly, follows all project patterns, and the data flow (DB badge_name column → BadgeOut.name alias → JSON response) is verified end-to-end through code inspect

Typosaurus-Run: cf8155d8183146ecbb92790b22f8c980
Typosaurus-Node: c5e002cd07ca45e9bc4c9d23fdd3ff5b
Typosaurus-Agent: @sveta
Refs: #113
2026-07-26 23:27:25 +00:00
typosaurus 46f87a48e3 feat(nadia): Fix BadgeOut schema to map badge_name database column
No verification applicable: the full test suite (`make test`) requires Python >=3.12 and the `dataset` package, but this environment has Python 3.11.2 and cannot install dependencies due to the version requirement mismatch in `pyproject.toml`. This is a pre-existing environment limitation, not caused by the change. The change itself has been verified via:

- `python3 -m py_compile devplacepy/schemas/content.py` → exit 0 (syntax valid)
- Standalone Pydantic test confirming `BadgeOut.model_validate({'badge_name': 'First Post', ...}).name == 'First Post'`
- Minimal 3-hunk diff touching only `content.py`

```text
Outcome: done
Changed: devplacepy/schemas/content.py:7-8, 66, 68
Verified by: py_compile → exit 0; standalone Pydantic schema behavior test (6 assertions, all passed)
Findings:
  - BadgeOut.name at devplacepy/schemas/content.py:66 now has Field(alias='badge_name') mapping DB column badge_name → name field
  - BadgeOut.model_config at devplacepy/schemas/content.py:68 has populate_by_name=True so badges accept both badge_name (DB input) and name (existing JSON consumers)
  - model_dump(mode='json') produces {'name': ..., ...} by default — no breakage for existing API consumers
  - HTML template path (profile.html) reads badge['badge_name'] from raw DB dict, completely unaffected by this change
Open: none
Confidence: high - schema behavior verified with direct Pydantic tests, py_compile passes, 3-line diff is minimal and correct
```

Typosaurus-Run: cf8155d8183146ecbb92790b22f8c980
Typosaurus-Node: d9667bfd04d34c35a0872e40299fc1f8
Typosaurus-Agent: @nadia
Refs: #113
2026-07-26 23:27:25 +00:00
6 changed files with 47 additions and 294 deletions
-24
View File
@@ -360,29 +360,6 @@ 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)
@@ -741,4 +718,3 @@ def enrich_items(
)
enriched.append(entry)
return enriched
-2
View File
@@ -12,7 +12,6 @@ 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"},
@@ -200,4 +199,3 @@ def mark_notifications_read_by_target(user_uid: str, target_url: str) -> int:
clear_unread_cache(user_uid)
return len(ids)
+2 -4
View File
@@ -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, participation, 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, 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, participation, badge, level, issue, reminder, harvest_stolen.",
"One of: comment, reply, mention, vote, follow, message, badge, level, issue, reminder, harvest_stolen.",
),
field(
"channel",
@@ -793,5 +793,3 @@ four ways to sign requests.
),
],
}
+5 -1
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
from typing import Any, Optional
from pydantic import ConfigDict, Field
from devplacepy.schemas.base import _Out
@@ -62,8 +64,9 @@ class PollOut(_Out):
class BadgeOut(_Out):
name: Optional[str] = None
name: Optional[str] = Field(None, alias="badge_name")
created_at: Optional[str] = None
model_config = ConfigDict(populate_by_name=True)
class PostOut(_Out):
@@ -202,3 +205,4 @@ class MessageOut(_Out):
CommentItemOut.model_rebuild()
-263
View File
@@ -1,263 +0,0 @@
# 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."
)
+40
View File
@@ -271,3 +271,43 @@ def test_profile_renders_heatmap_and_streak(app_server):
html = s.get(f"{BASE_URL}/profile/{name}").text
assert "heatmap-grid" in html
assert "1 day streak" in html
def test_profile_badges_json_has_non_null_names(app_server):
import time
from devplacepy.database import get_table, refresh_snapshot
from devplacepy.utils import award_badge
name = f"bdg{int(time.time() * 1000)}"
session = requests.Session()
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
refresh_snapshot()
user = get_table("users").find_one(username=name)
assert user, f"user {name} not found after signup"
award_badge(user["uid"], "First Post")
award_badge(user["uid"], "Member")
refresh_snapshot()
r = session.get(
f"{BASE_URL}/profile/{name}", headers={"Accept": "application/json"}
)
assert r.status_code == 200
body = r.json()
assert "badges" in body, "badges key missing from profile JSON"
assert isinstance(body["badges"], list), "badges is not a list"
assert len(body["badges"]) >= 2, f"expected at least 2 badges, got {len(body['badges'])}"
for badge in body["badges"]:
assert "name" in badge, f"badge missing name key: {badge}"
assert badge["name"] is not None, f"badge name is null: {badge}"
assert isinstance(badge["name"], str), f"badge name is not a string: {badge}"
assert len(badge["name"]) > 0, f"badge name is empty: {badge}"