feat: add threaded comments with polymorphic targets and slug-based routing

Introduce `load_comments` helper supporting polymorphic target types (post, project, bug) with nested parent-child threading. Refactor comment creation to use `target_type`/`target_uid` and `resolve_target_redirect` for correct redirects. Replace raw UID-based post/project routes with slug-aware resolution via `resolve_post`/`resolve_project` and `make_combined_slug`. Update SEO, sitemap, and FAB styling to use slugs and hardcoded red accent. Add reusable `_comment_section.html` template.
This commit is contained in:
2026-05-11 18:49:45 +00:00
parent 1e0960e0dd
commit f7ca123cd6
20 changed files with 427 additions and 222 deletions
+9 -6
View File
@@ -6,6 +6,11 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent
BASE_URL = "http://127.0.0.1:10501"
SCREENSHOT_DIR = Path("/tmp/devplace_test_screenshots")
_TEST_DB = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
_TEST_DB.close()
os.environ["DEVPLACE_DATABASE_URL"] = f"sqlite:///{_TEST_DB.name}"
os.environ["SECRET_KEY"] = "test-secret-key"
def save_failure_screenshot(page, test_name):
SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
@@ -32,21 +37,19 @@ def pytest_runtest_makereport(item, call):
except Exception:
pass
@pytest.fixture(scope="session")
def test_db_path():
tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
tmp.close()
yield tmp.name
yield _TEST_DB.name
try:
os.unlink(tmp.name)
os.unlink(_TEST_DB.name)
except OSError:
pass
@pytest.fixture(scope="session")
def app_server(test_db_path):
env = os.environ.copy()
env["DEVPLACE_DATABASE_URL"] = f"sqlite:///{test_db_path}"
env["SECRET_KEY"] = "test-secret-key"
env["PYTHONUNBUFFERED"] = "1"
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "devplacepy.main:app",
+65 -8
View File
@@ -14,18 +14,13 @@ def test_bugs_create_modal(alice):
assert page.is_visible("h3:has-text('Report a Bug')")
def test_bugs_create_submit(alice):
def test_bugs_empty_state(alice):
page, _ = alice
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
page.click("button:has-text('Report Bug')")
page.fill("#bug-title", "Test bug report")
page.fill("#bug-description", "This is a test bug description")
page.click("button:has-text('Submit Report')")
page.wait_for_timeout(500)
assert page.is_visible("text=Test bug report")
assert page.is_visible("text=No bug reports yet.")
def test_bugs_empty_state(alice):
def test_bugs_create_submit(alice):
page, _ = alice
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
assert page.is_visible("text=No bug reports yet.")
@@ -53,3 +48,65 @@ def test_bugs_unauth_empty(alice):
page, _ = alice
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
assert page.is_visible("h1:has-text('Bug Reports')")
def test_bug_comments_form_visible(alice):
page, _ = alice
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
page.click("button:has-text('Report Bug')")
page.fill("#bug-title", "Bug with comments")
page.fill("#bug-description", "Testing comment form on bug")
page.click("button:has-text('Submit Report')")
page.wait_for_timeout(500)
assert page.is_visible("text=Comments")
assert page.is_visible("textarea[name='content']")
def test_bug_comment_create(alice):
page, _ = alice
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
page.click("button:has-text('Report Bug')")
page.fill("#bug-title", "Bug comment test")
page.fill("#bug-description", "Bug description for comment")
page.click("button:has-text('Submit Report')")
page.wait_for_timeout(500)
page.fill("textarea[name='content']", "Bug comment here")
page.click("button:has-text('Post')")
page.wait_for_timeout(500)
assert page.is_visible("text=Bug comment here")
def test_bug_comment_reply(alice):
page, _ = alice
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
page.click("button:has-text('Report Bug')")
page.fill("#bug-title", "Bug threaded reply")
page.fill("#bug-description", "Bug for thread test")
page.click("button:has-text('Submit Report')")
page.wait_for_timeout(500)
page.fill("textarea[name='content']", "Root comment")
page.click("button:has-text('Post')")
page.wait_for_timeout(500)
assert page.is_visible("text=Root comment")
page.click("button:has-text('Reply')")
page.fill("textarea[name='content']", "Nested reply")
page.click("button:has-text('Post')")
page.wait_for_timeout(500)
assert page.is_visible("text=Nested reply")
def test_bug_comment_delete(alice):
page, _ = alice
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
page.click("button:has-text('Report Bug')")
page.fill("#bug-title", "Bug delete comment")
page.fill("#bug-description", "Bug for delete test")
page.click("button:has-text('Submit Report')")
page.wait_for_timeout(500)
page.fill("textarea[name='content']", "Delete me")
page.click("button:has-text('Post')")
page.wait_for_timeout(500)
assert page.is_visible("text=Delete me")
page.locator(".comment-body:has-text('Delete me') .comment-action-btn:has-text('Delete')").click()
page.wait_for_timeout(500)
assert not page.is_visible("text=Delete me")
+4 -3
View File
@@ -105,10 +105,11 @@ def test_full_user_journey(browser, app_server):
pa.locator("#platforms-input").fill(plat)
pa.locator("#platforms-input").press("Enter")
pa.click("button:has-text('Create Project')")
pa.wait_for_timeout(500)
pa.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
assert pa.is_visible("text=N8Nme")
# Second project — released
pa.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
pa.locator("#create-project-btn").click()
pa.fill("#title", "DWN")
pa.fill("#description", "Decentralized web node in Rust.")
@@ -117,7 +118,7 @@ def test_full_user_journey(browser, app_server):
pa.locator("#platforms-input").fill("Linux")
pa.locator("#platforms-input").press("Enter")
pa.click("button:has-text('Create Project')")
pa.wait_for_timeout(500)
pa.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
assert pa.is_visible("text=DWN")
# ═══════════════════════════════════════════════
@@ -213,7 +214,7 @@ def test_full_user_journey(browser, app_server):
pa.bring_to_front()
pa.goto(post1_url)
assert pa.is_visible("text=Nice work! What framework did you use?")
pa.locator("textarea[name='content']").fill("Thanks! I used FastAPI with SQLAlchemy. Vanilla JS frontend.")
pa.locator(".comment-form textarea[name='content']").fill("Thanks! I used FastAPI with SQLAlchemy. Vanilla JS frontend.")
pa.click("button:has-text('Post')")
pa.wait_for_timeout(500)
assert pa.is_visible("text=Thanks! I used FastAPI")
+69 -11
View File
@@ -52,7 +52,7 @@ def test_create_project_full(alice):
page.locator("#platforms-input").fill("PC")
page.locator("#platforms-input").press("Enter")
page.click("button:has-text('Create Project')")
page.wait_for_timeout(500)
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
assert page.is_visible("text=Playwright Test Game")
@@ -65,7 +65,7 @@ def test_create_project_software(alice):
page.check("input[value='software']")
page.check("input[value='Released']")
page.click("button:has-text('Create Project')")
page.wait_for_timeout(500)
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
assert page.is_visible("text=CLI Tool")
@@ -81,7 +81,7 @@ def test_create_project_mobile_app(alice):
page.locator("#platforms-input").fill("Android")
page.locator("#platforms-input").press("Enter")
page.click("button:has-text('Create Project')")
page.wait_for_timeout(500)
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
assert page.is_visible("text=Mobile Messenger")
@@ -92,7 +92,8 @@ def test_project_search(alice):
page.fill("#title", "SearchableProject")
page.fill("#description", "Find me via search")
page.click("button:has-text('Create Project')")
page.wait_for_timeout(500)
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.fill("input[placeholder='Search projects...']", "SearchableProject")
page.locator("input[placeholder='Search projects...']").press("Enter")
page.wait_for_timeout(500)
@@ -135,9 +136,6 @@ def test_project_detail_page(alice):
page.fill("#title", "DetailTestProject")
page.fill("#description", "Project detail page test")
page.click("button:has-text('Create Project')")
page.wait_for_timeout(500)
card = page.locator("a.project-card:has-text('DetailTestProject')").first
card.click()
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
assert page.is_visible("text=DetailTestProject")
assert page.is_visible("text=Back to Projects")
@@ -154,11 +152,71 @@ def test_project_detail_back_link(alice):
page.fill("#title", "BackLinkProject")
page.fill("#description", "Test back link")
page.click("button:has-text('Create Project')")
page.wait_for_timeout(500)
card = page.locator("a.project-card:has-text('BackLinkProject')").first
card.click()
page.wait_for_timeout(500)
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
back = page.locator("a:has-text('Back to Projects')")
assert back.is_visible()
back.click()
page.wait_for_url(f"{BASE_URL}/projects", wait_until="domcontentloaded")
def test_project_comments_form_visible(alice):
page, _ = alice
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.locator("#create-project-btn").click()
page.fill("#title", "CommentProject")
page.fill("#description", "Project for testing comments")
page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
assert page.is_visible("text=Comments")
assert page.is_visible("text=No comments yet")
assert page.is_visible("textarea[name='content']")
def test_project_comment_create(alice):
page, _ = alice
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.locator("#create-project-btn").click()
page.fill("#title", "CommentCreateProj")
page.fill("#description", "Project for creating a comment")
page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
page.fill("textarea[name='content']", "Great project!")
page.click("button:has-text('Post')")
page.wait_for_timeout(500)
assert page.is_visible("text=Great project!")
def test_project_comment_reply(alice):
page, _ = alice
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.locator("#create-project-btn").click()
page.fill("#title", "CommentReplyProj")
page.fill("#description", "Project for testing reply")
page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
page.fill("textarea[name='content']", "First comment")
page.click("button:has-text('Post')")
page.wait_for_timeout(500)
assert page.is_visible("text=First comment")
page.click("button:has-text('Reply')")
page.fill("textarea[name='content']", "Reply to comment")
page.click("button:has-text('Post')")
page.wait_for_timeout(500)
assert page.is_visible("text=Reply to comment")
def test_project_comment_delete(alice):
page, _ = alice
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.locator("#create-project-btn").click()
page.fill("#title", "CommentDeleteProj")
page.fill("#description", "Project for testing delete")
page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
page.fill("textarea[name='content']", "Comment to delete")
page.click("button:has-text('Post')")
page.wait_for_timeout(500)
assert page.is_visible("text=Comment to delete")
page.locator(".comment-action-btn:has-text('Delete')").click()
page.wait_for_timeout(500)
assert not page.is_visible("text=Comment to delete")