Compare commits

..

7 Commits

Author SHA1 Message Date
78c21cc507 Spacing
Some checks failed
DevPlace CI / test (push) Failing after 2m6s
2026-05-16 03:29:43 +02:00
f1fb8b8c16 Repaired gists. 2026-05-16 03:10:55 +02:00
f65a4aed9a Repaired chat 2026-05-16 03:02:10 +02:00
c6ad62063a Click on profile page and downvote button 2026-05-16 02:58:43 +02:00
06f884b827 Notification fix. 2026-05-16 02:49:53 +02:00
d74c87a733 Mention fix 2026-05-16 02:33:14 +02:00
3532497f3c Downvote button and click on post 2026-05-16 02:31:11 +02:00
22 changed files with 415 additions and 51 deletions

View File

@ -223,7 +223,7 @@ File validation: max 5MB, allowed extensions: `.png`, `.jpg`, `.jpeg`, `.gif`, `
- **148 tests across 14 files.** Playwright integration + unit tests. All must pass before any merge.
- **Tests use `-x` (fail-fast).** The suite stops at the first failure. Fix that test, then re-run.
- **Never run the full test suite unless specifically asked by user.** If something needs to be tested, run only the affected test file. Leave the others alone to speed up the development process.
- **NEVER run tests unless specifically asked by user.** Not the full suite, not a single file — do not run any tests unless the user explicitly requests it.
- **`hawk .` validates Python (compile + AST), JS (bracket matching), CSS (brace matching), HTML (tag matching).** Zero tolerance.
### Playwright navigation
@ -542,7 +542,7 @@ hawk .
Zero errors required.
### Step 5: Run existing tests
### Step 5: Run existing tests (only if asked by user)
```bash
make test
@ -560,7 +560,7 @@ All tests must pass. Tests stop at first failure (`-x`).
- Test both success paths and error/validation paths
- For delete buttons, scope to the specific element type (e.g., `.comment-action-btn`)
### Step 7: Run full suite again
### Step 7: Run full suite again (only if asked by user)
```bash
hawk .
@ -587,13 +587,9 @@ while feature_not_complete:
1. Plan: read existing code, design the change
2. Implement: write code (router → template → CSS → JS)
3. hawk . # must pass
4. make test # all must pass, -x stops at first failure
5. If tests fail: diagnose → fix → goto 3
6. Update tests if new functionality was added
7. make test # re-verify after test changes
8. falcon take + describe # visual check for UI changes
9. If visual fail: fix CSS/template → goto 3
10. Update AGENTS.md if needed
4. falcon take + describe # visual check for UI changes
5. If visual fail: fix CSS/template → goto 3
6. Update AGENTS.md if needed
```
Failures at any step block the workflow. Never skip a failed step.

View File

@ -4,6 +4,7 @@ from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse
from devplacepy.database import get_table, resolve_by_slug
from devplacepy.attachments import link_attachments, delete_target_attachments
from devplacepy.templating import clear_unread_cache
from devplacepy.utils import generate_uid, require_user, create_mention_notifications
logger = logging.getLogger(__name__)
@ -91,6 +92,7 @@ async def create_comment(request: Request):
"read": False,
"created_at": datetime.now(timezone.utc).isoformat(),
})
clear_unread_cache(parent["user_uid"])
else:
posts = get_table("posts")
post = posts.find_one(uid=target_uid)
@ -107,6 +109,7 @@ async def create_comment(request: Request):
"read": False,
"created_at": datetime.now(timezone.utc).isoformat(),
})
clear_unread_cache(post["user_uid"])
create_mention_notifications(content, user["uid"], redirect_url)
logger.info(f"Comment by {user['username']} on {target_type} {target_uid}")

View File

@ -3,6 +3,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse
from devplacepy.database import get_table
from devplacepy.templating import clear_unread_cache
from devplacepy.utils import generate_uid, require_user
logger = logging.getLogger(__name__)
@ -39,6 +40,7 @@ async def follow_user(request: Request, username: str):
"read": False,
"created_at": datetime.now(timezone.utc).isoformat(),
})
clear_unread_cache(target["uid"])
logger.info(f"{user['username']} followed {username}")
return RedirectResponse(url=f"/profile/{username}", status_code=302)

View File

@ -4,7 +4,7 @@ from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from devplacepy.database import get_table, db
from devplacepy.attachments import get_attachments_batch
from devplacepy.templating import templates
from devplacepy.templating import templates, clear_unread_cache
from devplacepy.utils import generate_uid, require_user, time_ago, create_mention_notifications
from devplacepy.seo import base_seo_context
@ -179,6 +179,7 @@ async def send_message(request: Request):
"read": False,
"created_at": datetime.now(timezone.utc).isoformat(),
})
clear_unread_cache(receiver_uid)
create_mention_notifications(content, user["uid"], f"/messages?with_uid={receiver_uid}")

View File

@ -183,5 +183,6 @@ async def create_project(request: Request):
if attachment_uids:
link_attachments(attachment_uids, "project", uid)
create_mention_notifications(description, user["uid"], f"/projects/{project_slug}")
logger.info(f"Project {uid} created by {user['username']}")
return RedirectResponse(url=f"/projects/{project_slug}", status_code=302)

View File

@ -3,6 +3,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse
from devplacepy.database import get_table
from devplacepy.templating import clear_unread_cache
from devplacepy.utils import generate_uid, require_user
logger = logging.getLogger(__name__)
@ -79,6 +80,7 @@ async def vote(request: Request, target_type: str, target_uid: str):
"read": False,
"created_at": datetime.now(timezone.utc).isoformat(),
})
clear_unread_cache(target_owner_uid)
referer = request.headers.get("Referer", "/feed")
return RedirectResponse(url=referer, status_code=302)

View File

@ -112,12 +112,23 @@
margin-bottom: 0.75rem;
}
.post-title-link {
text-decoration: none;
color: inherit;
display: block;
}
.post-title-link:hover .post-title {
color: var(--accent);
}
.post-title {
font-size: 1.125rem;
font-weight: 700;
margin-bottom: 0.5rem;
color: var(--text-primary);
line-height: 1.3;
transition: color 0.15s ease;
}
.post-content {
@ -130,6 +141,12 @@
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
cursor: pointer;
transition: color 0.15s ease;
}
.post-content:hover {
color: var(--accent);
}
.post-actions {
@ -166,6 +183,24 @@
color: var(--accent);
}
.post-action-btn.vote-down {
color: var(--danger);
}
.post-votes {
display: inline-flex;
align-items: center;
gap: 0.125rem;
}
.post-vote-count {
font-weight: 600;
font-size: 0.8125rem;
color: var(--text-secondary);
min-width: 1.25rem;
text-align: center;
}
.post-action-btn.share {
margin-left: auto;
}

View File

@ -169,25 +169,44 @@
}
.messages-input-area {
padding: 1rem;
padding: 0.75rem 1rem;
border-top: 1px solid var(--border);
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
gap: 0.5rem;
}
.messages-input-area input {
.messages-input-area input[type="text"] {
flex: 1;
min-width: 0;
}
.messages-input-area .attachment-upload-container {
flex-shrink: 0;
display: flex;
align-items: center;
}
.messages-input-area .attachment-preview-list {
flex: 0 0 100%;
order: -1;
margin-top: 0;
margin-bottom: 0.5rem;
}
.messages-send-btn {
padding: 0.5rem 1rem;
flex-shrink: 0;
width: 36px;
height: 36px;
padding: 0;
background: var(--accent);
color: #fff;
border-radius: var(--radius);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
transition: background 0.15s;
}
.messages-send-btn:hover {

View File

@ -239,6 +239,24 @@ button.comment-form-submit:hover {
color: var(--accent);
}
.post-action-btn.vote-down {
color: var(--danger);
}
.post-votes {
display: inline-flex;
align-items: center;
gap: 0.125rem;
}
.post-vote-count {
font-weight: 600;
font-size: 0.8125rem;
color: var(--text-secondary);
min-width: 1.25rem;
text-align: center;
}
.comment-thread-line {
position: absolute;
left: 16px;

View File

@ -125,7 +125,8 @@ class ContentRenderer {
if (m.index > last) {
parts.push({ type: "text", value: text.substring(last, m.index) });
}
const prefix = m[0][0];
const atIdx = m[0].lastIndexOf('@');
const prefix = m[0].slice(0, atIdx);
if (prefix) {
parts.push({ type: "text", value: prefix });
}

View File

@ -1,17 +1,18 @@
class GistEditor {
constructor(textareaId, langSelectId) {
this.editor = null;
this.initialized = false;
this.textareaId = textareaId || "gist-source-editor";
this.langSelectId = langSelectId || "gist-language";
this.init();
}
init() {
if (this.initialized) return;
if (typeof CodeMirror === "undefined") return;
const textarea = document.getElementById(this.textareaId);
if (!textarea || textarea.dataset.cminit) return;
textarea.dataset.cminit = "1";
if (!textarea) return;
this.initialized = true;
this.editor = CodeMirror.fromTextArea(textarea, {
lineNumbers: true,

View File

@ -157,7 +157,7 @@
<script defer src="https://cdn.jsdelivr.net/npm/marked/lib/marked.umd.js"></script>
<script defer src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/highlight.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/emoji-picker-element@^1/index.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/npm/emoji-picker-element@^1/index.js"></script>
<script type="module" src="/static/js/ContentRenderer.js"></script>
<script type="module" src="/static/js/AttachmentUploader.js"></script>
<script type="module" src="/static/js/EmojiPicker.js"></script>

View File

@ -42,10 +42,10 @@
<div class="feed-main">
<div class="feed-nav">
<a href="/feed?tab=all" class="feed-nav-btn {% if current_tab == 'all' or not current_tab %}active{% endif %}"><span class="icon">&#x1F4CB;</span>All</a>
<a href="/feed?tab=trending" class="feed-nav-btn {% if current_tab == 'trending' %}active{% endif %}"><span class="icon">&#x1F525;</span>Trending</a>
<a href="/feed?tab=recent" class="feed-nav-btn {% if current_tab == 'recent' %}active{% endif %}"><span class="icon">&#x1F550;</span>Recent</a>
<a href="/feed?tab=following" class="feed-nav-btn {% if current_tab == 'following' %}active{% endif %}"><span class="icon">&#x1F465;</span>Following</a>
<a href="/feed?tab=all" class="feed-nav-btn {% if current_tab == 'all' or not current_tab %}active{% endif %}"><span class="icon">&#x1F4CB;</span> All</a>
<a href="/feed?tab=trending" class="feed-nav-btn {% if current_tab == 'trending' %}active{% endif %}"><span class="icon">&#x1F525;</span> Trending</a>
<a href="/feed?tab=recent" class="feed-nav-btn {% if current_tab == 'recent' %}active{% endif %}"><span class="icon">&#x1F550;</span> Recent</a>
<a href="/feed?tab=following" class="feed-nav-btn {% if current_tab == 'following' %}active{% endif %}"><span class="icon">&#x1F465;</span> Following</a>
<div class="feed-nav-actions">
<button class="btn-ghost btn-icon" title="Refresh" onclick="window.location.reload()">&#x21BB;</button>
<button class="btn-ghost btn-icon" title="Notifications" onclick="window.location.href='/notifications'">&#x1F514;</button>
@ -71,22 +71,29 @@
</div>
{% if item.post.get('title') %}
<h3 class="post-title">{{ item.post['title'] }}</h3>
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-title-link">
<h3 class="post-title">{{ item.post['title'] }}</h3>
</a>
{% endif %}
<div class="post-content rendered-content" data-render>{{ item.post['content'][:300] }}{% if item.post['content']|length > 300 %}...{% endif %}</div>
<div class="post-content rendered-content" data-render onclick="if (!event.target.closest('a')) window.location.href='/posts/{{ item.post['slug'] or item.post['uid'] }}'">{{ item.post['content'][:300] }}{% if item.post['content']|length > 300 %}...{% endif %}</div>
{% if item.attachments %}
{% include "_attachment_display.html" %}
{% endif %}
<div class="post-actions">
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up" data-vote="1" data-target="{{ item.post['uid'] }}" data-type="post">
+{{ item.post.get('stars', 0) }}
</button>
</form>
<div class="post-votes">
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up" data-vote="1" data-target="{{ item.post['uid'] }}" data-type="post">+</button>
</form>
<span class="post-vote-count">{{ item.post.get('stars', 0) }}</span>
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="-1">
<button type="submit" class="post-action-btn vote-down" data-vote="-1" data-target="{{ item.post['uid'] }}" data-type="post"></button>
</form>
</div>
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn">
&#x1F4AC; {{ item.comment_count }}
</a>

View File

@ -84,7 +84,7 @@
</div>
<div class="auth-field auth-field-gap-last">
<label for="edit-gist-source-editor">Source Code</label>
<textarea id="edit-gist-source-editor" name="source_code" required class="hidden-textarea" data-language="{{ gist['language'] }}">{{ gist['source_code'] }}</textarea>
<textarea id="edit-gist-source-editor" name="source_code" class="hidden-textarea" data-language="{{ gist['language'] }}">{{ gist['source_code'] }}</textarea>
</div>
{% include "_attachment_form.html" %}
<div class="modal-footer">

View File

@ -111,7 +111,7 @@
<div class="auth-field auth-field-gap-last">
<label for="gist-source-editor">Source Code</label>
<textarea id="gist-source-editor" name="source_code" required class="hidden-textarea" data-language="plaintext"></textarea>
<textarea id="gist-source-editor" name="source_code" class="hidden-textarea" data-language="plaintext"></textarea>
</div>
{% include "_attachment_form.html" %}
@ -135,7 +135,6 @@
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.18/mode/xml/xml.min.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.18/mode/css/css.min.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.18/mode/go/go.min.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.18/mode/rust/rust.min.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.18/mode/sql/sql.min.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.18/mode/shell/shell.min.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.18/mode/yaml/yaml.min.js"></script>
@ -157,6 +156,7 @@ const gistModal = document.getElementById("create-gist-modal");
if (gistModal) {
const observer = new MutationObserver(() => {
if (gistModal.classList.contains("visible")) {
gistEditor.init();
gistEditor.refresh();
}
});

View File

@ -68,4 +68,21 @@
</div>
</div>
</div>
{% block extra_js %}
{% if other_user %}
<script>
document.addEventListener("DOMContentLoaded", function() {
var input = document.querySelector('.messages-input-area input[name="content"]');
if (input) {
var scrollTarget = document.querySelector('.messages-thread');
if (scrollTarget) {
scrollTarget.scrollTop = scrollTarget.scrollHeight;
}
input.focus();
}
});
</script>
{% endif %}
{% endblock %}
{% endblock %}

View File

@ -36,10 +36,17 @@
{% endif %}
<div class="post-detail-actions">
<form method="POST" action="/votes/post/{{ post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up">+{{ post.get('stars', 0) }}</button>
</form>
<div class="post-votes">
<form method="POST" action="/votes/post/{{ post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up">+</button>
</form>
<span class="post-vote-count">{{ post.get('stars', 0) }}</span>
<form method="POST" action="/votes/post/{{ post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="-1">
<button type="submit" class="post-action-btn vote-down"></button>
</form>
</div>
<button class="post-action-btn">&#x1F517; Share</button>
{% if user and post['user_uid'] == user['uid'] %}
<button class="post-action-btn" data-modal="edit-post-modal"><span class="icon">&#x270F;&#xFE0F;</span>Edit</button>

View File

@ -172,12 +172,26 @@
<span class="badge badge-{{ item.post['topic'] }}">{{ item.post['topic'] }}</span>
</div>
{% if item.post.get('title') %}
<h3 class="post-title">{{ item.post['title'] }}</h3>
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-title-link">
<h3 class="post-title">{{ item.post['title'] }}</h3>
</a>
{% endif %}
<div class="post-content rendered-content" data-render>{{ item.post['content'][:300] }}{% if item.post['content']|length > 300 %}...{% endif %}</div>
<div class="post-content rendered-content" data-render onclick="if (!event.target.closest('a')) window.location.href='/posts/{{ item.post['slug'] or item.post['uid'] }}'">{{ item.post['content'][:300] }}{% if item.post['content']|length > 300 %}...{% endif %}</div>
<div class="post-actions">
<span class="post-action-btn post-action-static">+{{ item.post.get('stars', 0) }}</span>
<span class="post-action-btn post-action-static">&#x1F4AC; {{ item.comment_count }}</span>
<div class="post-votes">
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up">+</button>
</form>
<span class="post-vote-count">{{ item.post.get('stars', 0) }}</span>
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="-1">
<button type="submit" class="post-action-btn vote-down"></button>
</form>
</div>
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn">
&#x1F4AC; {{ item.comment_count }}
</a>
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn share">&#x2197;&#xFE0E; Open</a>
</div>
</article>

View File

@ -43,13 +43,12 @@
</div>
<div class="projects-tabs">
<a href="/projects?tab=recent" class="projects-tab {% if current_tab == 'recent' or not current_tab %}active{% endif %}"><span class="icon">&#x1F550;</span>Recently Released</a>
<a href="/projects?tab=popular" class="projects-tab {% if current_tab == 'popular' %}active{% endif %}"><span class="icon">&#x1F525;</span>Most Popular</a>
<a href="/projects?tab=new" class="projects-tab {% if current_tab == 'new' %}active{% endif %}"><span class="icon">&#x1F195;</span>New This Week</a>
<a href="/projects?tab=recent" class="projects-tab {% if current_tab == 'recent' or not current_tab %}active{% endif %}"><span class="icon">&#x1F550;</span> Recently Released</a>
<a href="/projects?tab=popular" class="projects-tab {% if current_tab == 'popular' %}active{% endif %}"><span class="icon">&#x1F525;</span> Most Popular</a>
<a href="/projects?tab=new" class="projects-tab {% if current_tab == 'new' %}active{% endif %}"><span class="icon">&#x1F195;</span> New This Week</a>
{% if user %}
<a href="/projects?user_uid={{ user['uid'] }}" class="projects-tab {% if request.query_params.get('user_uid') %}active{% endif %}"><span class="icon">&#x1F464;</span>My Projects</a>
<a href="/projects?user_uid={{ user['uid'] }}" class="projects-tab {% if request.query_params.get('user_uid') %}active{% endif %}"><span class="icon">&#x1F464;</span> My Projects</a>
{% endif %}
<button class="btn-ghost btn-icon projects-settings-btn">&#x2699;</button>
</div>
<div class="projects-grid">

View File

@ -16,6 +16,10 @@ templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
_unread_cache = {}
_UNREAD_CACHE_TTL = 60
def clear_unread_cache(user_uid: str) -> None:
_unread_cache.pop(user_uid, None)
def jinja_unread_count(user_uid: str) -> int:
cached = _unread_cache.get(user_uid)
if cached:

View File

@ -134,6 +134,7 @@ def create_mention_notifications(content: str, actor_uid: str, target_url: str)
usernames = extract_mentions(content)
if not usernames:
return
from devplacepy.templating import clear_unread_cache
users = get_table("users")
notifs = get_table("notifications")
seen = set()
@ -153,6 +154,7 @@ def create_mention_notifications(content: str, actor_uid: str, target_url: str)
"read": False,
"created_at": datetime.now(timezone.utc).isoformat(),
})
clear_unread_cache(mentioned["uid"])
def format_date(dt_str: str, include_time: bool = False) -> str:

View File

@ -87,7 +87,6 @@ def test_vote_notification_on_post(app_server, browser, seeded_db):
vote_btn.click()
pa.wait_for_timeout(1500)
# Check if vote actually worked by looking at Alice's page after vote
pa.wait_for_timeout(500)
pa_body = pa.locator("body").text_content()
assert "Internal Server Error" not in pa_body, f"Alice got 500 after vote: {pa_body[:300]}"
@ -101,3 +100,239 @@ def test_vote_notification_on_post(app_server, browser, seeded_db):
ctx_a.close()
ctx_b.close()
def test_comment_notification_on_post(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
pb = ctx_b.new_page()
pa.set_default_timeout(15000)
pb.set_default_timeout(15000)
login_user(pa, seeded_db["alice"])
login_user(pb, seeded_db["bob"])
pa.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
pa.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
pa.locator(".feed-fab").first.click()
pa.fill("#post-content", "Post for comment notification test")
pa.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
pa.wait_for_url("**/posts/*", timeout=10000, wait_until="domcontentloaded")
post_url = pa.url
pb.goto(post_url, wait_until="domcontentloaded")
pb.wait_for_timeout(1000)
comment_textarea = pb.locator("form.comment-form textarea[name='content']").first
comment_textarea.wait_for(state="visible", timeout=10000)
comment_textarea.fill("Nice post!")
pb.locator("button.comment-form-submit").first.click()
pb.wait_for_timeout(1500)
pb_body = pb.locator("body").text_content()
assert "Internal Server Error" not in pb_body, f"Bob got 500: {pb_body[:300]}"
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
pa.wait_for_timeout(1500)
body = pa.locator("body").text_content()
assert "Internal Server Error" not in body, f"Got 500 error on notifications: {body[:500]}"
assert "bob_test" in body, f"Expected 'bob_test' in notifications, got: {body[:500]}"
assert "commented on your" in body, f"Expected 'commented on your' in notifications, got: {body[:500]}"
ctx_a.close()
ctx_b.close()
def test_follow_notification(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
pb = ctx_b.new_page()
pa.set_default_timeout(15000)
pb.set_default_timeout(15000)
login_user(pa, seeded_db["alice"])
login_user(pb, seeded_db["bob"])
pb.goto(f"{BASE_URL}/profile/alice_test", wait_until="domcontentloaded")
pb.wait_for_timeout(1000)
follow_btn = pb.locator("button:has-text('Follow')")
if follow_btn.is_visible():
follow_btn.click()
pb.wait_for_timeout(1000)
pb_body = pb.locator("body").text_content()
assert "Internal Server Error" not in pb_body, f"Bob got 500: {pb_body[:300]}"
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
pa.wait_for_timeout(1500)
body = pa.locator("body").text_content()
assert "Internal Server Error" not in body, f"Got 500 error on notifications: {body[:500]}"
assert "bob_test" in body, f"Expected 'bob_test' in notifications, got: {body[:500]}"
assert "started following you" in body, f"Expected 'started following you' in notifications, got: {body[:500]}"
ctx_a.close()
ctx_b.close()
def test_message_notification(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
pb = ctx_b.new_page()
pa.set_default_timeout(15000)
pb.set_default_timeout(15000)
login_user(pa, seeded_db["alice"])
login_user(pb, seeded_db["bob"])
pb.goto(f"{BASE_URL}/messages?search=alice_test", wait_until="domcontentloaded")
pb.wait_for_timeout(2000)
msg_input = pb.locator("input[name='content']").first
msg_input.wait_for(state="visible", timeout=10000)
msg_input.fill("Hello from bob_test!")
pb.locator("button[type='submit']").last.click()
pb.wait_for_timeout(1500)
pb_body = pb.locator("body").text_content()
assert "Internal Server Error" not in pb_body, f"Bob got 500: {pb_body[:300]}"
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
pa.wait_for_timeout(1500)
body = pa.locator("body").text_content()
assert "Internal Server Error" not in body, f"Got 500 error on notifications: {body[:500]}"
assert "bob_test" in body, f"Expected 'bob_test' in notifications, got: {body[:500]}"
assert "sent you a message" in body, f"Expected 'sent you a message' in notifications, got: {body[:500]}"
ctx_a.close()
ctx_b.close()
def test_mention_notification_in_post(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
pb = ctx_b.new_page()
pa.set_default_timeout(15000)
pb.set_default_timeout(15000)
login_user(pa, seeded_db["alice"])
login_user(pb, seeded_db["bob"])
pb.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
pb.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
pb.locator(".feed-fab").first.click()
pb.fill("#post-content", "Check this out @alice_test how are you?")
pb.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
pb.wait_for_url("**/posts/*", timeout=10000, wait_until="domcontentloaded")
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
pa.wait_for_timeout(2000)
body = pa.locator("body").text_content()
assert "Internal Server Error" not in body, f"Got 500 error on notifications: {body[:500]}"
assert "bob_test" in body, f"Expected 'bob_test' in notifications for mention, got: {body[:500]}"
assert "mentioned you" in body, f"Expected 'mentioned you' in notifications, got: {body[:500]}"
ctx_a.close()
ctx_b.close()
def test_mention_notification_in_comment(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
pb = ctx_b.new_page()
pa.set_default_timeout(15000)
pb.set_default_timeout(15000)
login_user(pa, seeded_db["alice"])
login_user(pb, seeded_db["bob"])
pa.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
pa.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
pa.locator(".feed-fab").first.click()
pa.fill("#post-content", "Post for mention in comment test")
pa.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
pa.wait_for_url("**/posts/*", timeout=10000, wait_until="domcontentloaded")
post_url = pa.url
pb.goto(post_url, wait_until="domcontentloaded")
pb.wait_for_timeout(1000)
comment_textarea = pb.locator("form.comment-form textarea[name='content']").first
comment_textarea.wait_for(state="visible", timeout=10000)
comment_textarea.fill("Hey @alice_test look at this!")
pb.locator("button.comment-form-submit").first.click()
pb.wait_for_timeout(1500)
pb_body = pb.locator("body").text_content()
assert "Internal Server Error" not in pb_body, f"Bob got 500: {pb_body[:300]}"
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
pa.wait_for_timeout(2000)
body = pa.locator("body").text_content()
assert "Internal Server Error" not in body, f"Got 500 error on notifications: {body[:500]}"
assert "bob_test" in body, f"Expected 'bob_test' in notifications for mention, got: {body[:500]}"
assert "mentioned you" in body, f"Expected 'mentioned you' in notifications, got: {body[:500]}"
ctx_a.close()
ctx_b.close()
def test_reply_notification(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
pb = ctx_b.new_page()
pa.set_default_timeout(15000)
pb.set_default_timeout(15000)
login_user(pa, seeded_db["alice"])
login_user(pb, seeded_db["bob"])
pa.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
pa.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
pa.locator(".feed-fab").first.click()
pa.fill("#post-content", "Post for reply notification test")
pa.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
pa.wait_for_url("**/posts/*", timeout=10000, wait_until="domcontentloaded")
post_url = pa.url
pa.goto(post_url, wait_until="domcontentloaded")
pa.wait_for_timeout(1000)
comment_textarea = pa.locator("form.comment-form textarea[name='content']").first
comment_textarea.wait_for(state="visible", timeout=10000)
comment_textarea.fill("Alice's top-level comment")
pa.locator("button.comment-form-submit").first.click()
pa.wait_for_timeout(1500)
pb.goto(post_url, wait_until="domcontentloaded")
pb.wait_for_timeout(1000)
reply_btn = pb.locator("button:has-text('Reply')").first
if reply_btn.is_visible():
reply_btn.click()
pb.wait_for_timeout(500)
reply_textarea = pb.locator("form.comment-form textarea[name='content']").first
if reply_textarea.is_visible():
reply_textarea.fill("Bob's reply to Alice's comment")
pb.locator("button.comment-form-submit").first.click()
pb.wait_for_timeout(1500)
pb_body = pb.locator("body").text_content()
assert "Internal Server Error" not in pb_body, f"Bob got 500: {pb_body[:300]}"
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
pa.wait_for_timeout(2000)
body = pa.locator("body").text_content()
assert "Internal Server Error" not in body, f"Got 500 error on notifications: {body[:500]}"
assert "bob_test" in body, f"Expected 'bob_test' in notifications, got: {body[:500]}"
assert "replied to your comment" in body, f"Expected 'replied to your comment' in notifications, got: {body[:500]}"
ctx_a.close()
ctx_b.close()