2026-06-13 16:32:33 +02:00
|
|
|
# retoor <retoor@molodetz.nl>
|
|
|
|
|
|
2026-06-19 10:06:09 +02:00
|
|
|
import re
|
2026-06-13 16:32:33 +02:00
|
|
|
import time
|
2026-06-19 10:06:09 +02:00
|
|
|
from playwright.sync_api import expect
|
2026-06-13 16:32:33 +02:00
|
|
|
from tests.conftest import BASE_URL
|
|
|
|
|
from devplacepy.database import get_table
|
|
|
|
|
import requests
|
|
|
|
|
def _seed_news_seo():
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
from devplacepy.database import get_table
|
|
|
|
|
from devplacepy.utils import generate_uid, make_combined_slug
|
|
|
|
|
|
|
|
|
|
uid = generate_uid()
|
|
|
|
|
title = f"SEO Test News Article {uid.split('-')[-1]}"
|
|
|
|
|
slug = make_combined_slug(title, uid)
|
|
|
|
|
get_table("news").insert(
|
|
|
|
|
{
|
2026-06-14 16:46:36 +02:00
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
2026-06-13 16:32:33 +02:00
|
|
|
"uid": uid,
|
|
|
|
|
"slug": slug,
|
|
|
|
|
"title": title,
|
|
|
|
|
"description": "A seeded news article for SEO tests.",
|
|
|
|
|
"content": "Body content for the seeded article.",
|
|
|
|
|
"url": "https://example.com/article",
|
|
|
|
|
"source_name": "ExampleSource",
|
|
|
|
|
"status": "published",
|
|
|
|
|
"synced_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
"show_on_landing": 0,
|
|
|
|
|
"grade": 8,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return slug, uid
|
|
|
|
|
def _seed_owner():
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
from devplacepy.database import get_table
|
|
|
|
|
|
|
|
|
|
uid = str(uuid4())
|
|
|
|
|
get_table("users").insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": uid,
|
|
|
|
|
"username": f"seo_{uid[:8]}",
|
|
|
|
|
"email": f"{uid[:8]}@seo.test",
|
|
|
|
|
"password_hash": "x",
|
|
|
|
|
"role": "Member",
|
|
|
|
|
"is_active": True,
|
|
|
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return uid
|
|
|
|
|
def _seed_post_seo(image=None):
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
from devplacepy.database import get_table
|
|
|
|
|
from devplacepy.utils import make_combined_slug
|
|
|
|
|
|
|
|
|
|
owner = _seed_owner()
|
|
|
|
|
uid = str(uuid4())
|
|
|
|
|
slug = make_combined_slug("SEO Detail Post", uid)
|
|
|
|
|
get_table("posts").insert(
|
|
|
|
|
{
|
2026-06-14 16:46:36 +02:00
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
2026-06-13 16:32:33 +02:00
|
|
|
"uid": uid,
|
|
|
|
|
"user_uid": owner,
|
|
|
|
|
"slug": slug,
|
|
|
|
|
"title": "SEO Detail Post",
|
|
|
|
|
"content": "Body text for the SEO detail post.",
|
|
|
|
|
"topic": "general",
|
|
|
|
|
"project_uid": None,
|
|
|
|
|
"image": image,
|
|
|
|
|
"stars": 0,
|
|
|
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return slug, uid
|
|
|
|
|
def _seed_gist():
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
from devplacepy.database import get_table
|
|
|
|
|
from devplacepy.utils import make_combined_slug
|
|
|
|
|
|
|
|
|
|
owner = _seed_owner()
|
|
|
|
|
uid = str(uuid4())
|
|
|
|
|
slug = make_combined_slug("SEO Detail Gist", uid)
|
|
|
|
|
get_table("gists").insert(
|
|
|
|
|
{
|
2026-06-14 16:46:36 +02:00
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
2026-06-13 16:32:33 +02:00
|
|
|
"uid": uid,
|
|
|
|
|
"user_uid": owner,
|
|
|
|
|
"slug": slug,
|
|
|
|
|
"title": "SEO Detail Gist",
|
|
|
|
|
"description": "Gist description for SEO tests.",
|
|
|
|
|
"source_code": "print('seo')",
|
|
|
|
|
"language": "python",
|
|
|
|
|
"stars": 0,
|
|
|
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return slug, uid
|
|
|
|
|
def _seed_project():
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
from devplacepy.database import get_table
|
|
|
|
|
from devplacepy.utils import make_combined_slug
|
|
|
|
|
|
|
|
|
|
owner = _seed_owner()
|
|
|
|
|
uid = str(uuid4())
|
|
|
|
|
slug = make_combined_slug("SEO Detail Project", uid)
|
|
|
|
|
get_table("projects").insert(
|
|
|
|
|
{
|
2026-06-14 16:46:36 +02:00
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
2026-06-13 16:32:33 +02:00
|
|
|
"uid": uid,
|
|
|
|
|
"user_uid": owner,
|
|
|
|
|
"slug": slug,
|
|
|
|
|
"title": "SEO Detail Project",
|
|
|
|
|
"description": "Project description for SEO tests.",
|
|
|
|
|
"project_type": "software",
|
|
|
|
|
"platforms": "Linux",
|
|
|
|
|
"status": "Released",
|
|
|
|
|
"stars": 0,
|
|
|
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return slug, uid
|
|
|
|
|
def _seed_news_image(news_uid):
|
|
|
|
|
from devplacepy.database import get_table
|
|
|
|
|
|
|
|
|
|
get_table("news_images").insert(
|
|
|
|
|
{
|
2026-06-14 16:46:36 +02:00
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
2026-06-13 16:32:33 +02:00
|
|
|
"news_uid": news_uid,
|
|
|
|
|
"url": "https://example.com/seo-news-image.jpg",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
def _seed_feed_posts(count):
|
|
|
|
|
from datetime import datetime, timezone, timedelta
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
from devplacepy.database import get_table
|
|
|
|
|
from devplacepy.utils import make_combined_slug
|
|
|
|
|
|
|
|
|
|
owner = _seed_owner()
|
|
|
|
|
topic = f"seopag{owner[:8]}"
|
|
|
|
|
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
|
|
|
posts = get_table("posts")
|
|
|
|
|
for i in range(count):
|
|
|
|
|
uid = str(uuid4())
|
|
|
|
|
posts.insert(
|
|
|
|
|
{
|
2026-06-14 16:46:36 +02:00
|
|
|
"deleted_at": None,
|
|
|
|
|
"deleted_by": None,
|
2026-06-13 16:32:33 +02:00
|
|
|
"uid": uid,
|
|
|
|
|
"user_uid": owner,
|
|
|
|
|
"slug": make_combined_slug(f"seo pag {i}", uid),
|
|
|
|
|
"title": None,
|
|
|
|
|
"content": f"seo pag post {i}",
|
|
|
|
|
"topic": topic,
|
|
|
|
|
"project_uid": None,
|
|
|
|
|
"image": None,
|
|
|
|
|
"stars": 0,
|
|
|
|
|
"created_at": (base - timedelta(seconds=i)).isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return topic
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_send_message_appears_in_thread(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
bob = get_table("users").find_one(username="bob_test")
|
|
|
|
|
page.goto(
|
|
|
|
|
f"{BASE_URL}/messages?with_uid={bob['uid']}", wait_until="domcontentloaded"
|
|
|
|
|
)
|
|
|
|
|
msg = f"Hello bob {int(time.time() * 1000)}"
|
2026-07-23 00:02:43 +02:00
|
|
|
page.fill("textarea[name='content']", msg)
|
2026-06-13 16:32:33 +02:00
|
|
|
page.locator(".messages-send-btn").click()
|
|
|
|
|
page.wait_for_url("**/messages**", wait_until="domcontentloaded")
|
2026-06-19 00:09:34 +02:00
|
|
|
page.locator(f".message-bubble:has-text('{msg}')").first.wait_for(state="visible")
|
2026-06-13 16:32:33 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_messages_page_loads(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")
|
|
|
|
|
assert page.is_visible(".messages-layout")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_messages_search_input(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")
|
|
|
|
|
search = page.locator("#message-search")
|
|
|
|
|
assert search.is_visible()
|
|
|
|
|
assert search.get_attribute("placeholder") == "Search conversations..."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_messages_empty_state(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")
|
|
|
|
|
assert page.is_visible(".messages-layout") or page.is_visible(
|
|
|
|
|
"text=No conversations yet"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_messages_search_for_bob(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")
|
|
|
|
|
page.fill("#message-search", "bob_test")
|
|
|
|
|
result = page.locator(".search-dropdown-item:has-text('bob_test')")
|
|
|
|
|
result.wait_for(state="visible", timeout=5000)
|
|
|
|
|
assert result.is_visible()
|
|
|
|
|
|
|
|
|
|
|
2026-06-19 10:06:09 +02:00
|
|
|
def test_messages_search_first_result_highlighted(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")
|
|
|
|
|
page.fill("#message-search", "bob_test")
|
|
|
|
|
result = page.locator(".search-dropdown-item:has-text('bob_test')")
|
|
|
|
|
result.wait_for(state="visible", timeout=5000)
|
|
|
|
|
expect(result.first).to_have_class(re.compile(r"\bactive\b"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_messages_search_enter_opens_conversation(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")
|
|
|
|
|
page.fill("#message-search", "bob_test")
|
|
|
|
|
page.locator(".search-dropdown-item:has-text('bob_test')").wait_for(
|
|
|
|
|
state="visible", timeout=5000
|
|
|
|
|
)
|
|
|
|
|
page.locator("#message-search").press("Enter")
|
|
|
|
|
page.wait_for_url(re.compile(r"with_uid="), wait_until="domcontentloaded")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_messages_search_arrow_then_enter_opens_conversation(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")
|
|
|
|
|
page.fill("#message-search", "bob_test")
|
|
|
|
|
page.locator(".search-dropdown-item:has-text('bob_test')").wait_for(
|
|
|
|
|
state="visible", timeout=5000
|
|
|
|
|
)
|
|
|
|
|
search = page.locator("#message-search")
|
|
|
|
|
search.press("ArrowDown")
|
|
|
|
|
search.press("Enter")
|
|
|
|
|
page.wait_for_url(re.compile(r"with_uid="), wait_until="domcontentloaded")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_messages_search_escape_closes_dropdown(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")
|
|
|
|
|
page.fill("#message-search", "bob_test")
|
|
|
|
|
result = page.locator(".search-dropdown-item:has-text('bob_test')")
|
|
|
|
|
result.wait_for(state="visible", timeout=5000)
|
|
|
|
|
page.locator("#message-search").press("Escape")
|
|
|
|
|
expect(result).to_be_hidden()
|
|
|
|
|
|
|
|
|
|
|
2026-06-13 16:32:33 +02:00
|
|
|
def test_messages_header_visible(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")
|
|
|
|
|
assert page.is_visible("text=Home")
|
|
|
|
|
assert page.is_visible("text=Projects")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_messages_bell_icon(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")
|
|
|
|
|
bell = page.locator(".topnav-icon[href='/notifications']")
|
|
|
|
|
assert bell.is_visible()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_messages_topnav_user(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")
|
|
|
|
|
assert page.is_visible(f"text={user['username']}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_messages_avatar_on_page(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")
|
|
|
|
|
avatar = page.locator("img.avatar-img").first
|
|
|
|
|
assert avatar.is_visible()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_messages_page_is_noindex(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")
|
|
|
|
|
page.wait_for_url("**/messages", wait_until="domcontentloaded")
|
|
|
|
|
meta = page.locator('meta[name="robots"]')
|
|
|
|
|
content = meta.get_attribute("content")
|
|
|
|
|
assert content and "noindex" in content
|
2026-07-23 00:02:43 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_optimistic_send_pending_then_reconciles_no_double_render(alice, bob):
|
|
|
|
|
page_a, user_a = alice
|
|
|
|
|
page_b, user_b = bob
|
|
|
|
|
uid_a = get_table("users").find_one(username=user_a["username"])["uid"]
|
|
|
|
|
uid_b = get_table("users").find_one(username=user_b["username"])["uid"]
|
|
|
|
|
|
|
|
|
|
page_a.goto(
|
|
|
|
|
f"{BASE_URL}/messages?with_uid={uid_b}", wait_until="domcontentloaded"
|
|
|
|
|
)
|
|
|
|
|
page_b.goto(
|
|
|
|
|
f"{BASE_URL}/messages?with_uid={uid_a}", wait_until="domcontentloaded"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
msg = f"Optimistic hello {int(time.time() * 1000)}"
|
|
|
|
|
textarea = page_a.locator(".messages-input-area textarea[name='content']")
|
|
|
|
|
textarea.wait_for(state="visible")
|
|
|
|
|
textarea.fill(msg)
|
|
|
|
|
page_a.locator(".messages-send-btn").click()
|
|
|
|
|
|
|
|
|
|
pending = page_a.locator(f".message-bubble.mine.pending:has-text('{msg}')")
|
|
|
|
|
pending.wait_for(state="visible", timeout=5000)
|
|
|
|
|
client_id = pending.get_attribute("data-client-id")
|
|
|
|
|
assert client_id
|
|
|
|
|
|
|
|
|
|
reconciled = page_a.locator(
|
|
|
|
|
f".message-bubble.mine[data-client-id='{client_id}'][data-msg-uid]:not(.pending)"
|
|
|
|
|
)
|
|
|
|
|
reconciled.wait_for(state="visible", timeout=6000)
|
|
|
|
|
|
|
|
|
|
received = page_b.locator(f".message-bubble.theirs:has-text('{msg}')")
|
|
|
|
|
received.wait_for(state="visible", timeout=6000)
|
|
|
|
|
assert received.count() == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mobile_single_pane_back_button(mobile_page):
|
|
|
|
|
page, user = mobile_page
|
|
|
|
|
bob = get_table("users").find_one(username="bob_test")
|
|
|
|
|
page.goto(
|
|
|
|
|
f"{BASE_URL}/messages?with_uid={bob['uid']}", wait_until="domcontentloaded"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
thread = page.locator(".messages-main")
|
|
|
|
|
conv_list = page.locator(".messages-list")
|
|
|
|
|
thread.wait_for(state="visible")
|
|
|
|
|
expect(conv_list).to_be_hidden()
|
|
|
|
|
|
|
|
|
|
page.locator("#messages-back-btn").click()
|
|
|
|
|
|
|
|
|
|
expect(conv_list).to_be_visible()
|
|
|
|
|
expect(thread).to_be_hidden()
|
|
|
|
|
assert page.url == f"{BASE_URL}/messages"
|