feat: replace DiceBear avatar proxy with local Multiavatar SVG generation and remove avatar_style from signup

This commit is contained in:
2026-05-11 01:14:43 +00:00
parent d8ef8ebef2
commit fda3a202dc
37 changed files with 1203 additions and 366 deletions
+16 -17
View File
@@ -47,16 +47,14 @@ 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"
err_path = Path("/tmp/devplace_test_server_err.log")
with open(err_path, "w") as err_f:
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "devplacepy.main:app",
"--host", "127.0.0.1", "--port", "10501"],
cwd=str(PROJECT_ROOT),
env=env,
stdout=subprocess.DEVNULL,
stderr=err_f,
)
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "devplacepy.main:app",
"--host", "127.0.0.1", "--port", "10501"],
cwd=str(PROJECT_ROOT),
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
deadline = time.time() + 20
while time.time() < deadline:
try:
@@ -69,8 +67,13 @@ def app_server(test_db_path):
proc.terminate(); proc.wait()
raise RuntimeError("Server did not start")
yield proc
proc.terminate()
proc.wait(timeout=10)
try:
proc.terminate()
proc.wait(timeout=10)
except Exception:
proc.kill()
proc.wait(timeout=5)
@pytest.fixture(scope="session")
def playwright_instance():
@@ -120,11 +123,7 @@ def login_user(page, user):
page.fill("#email", user["email"])
page.fill("#password", user["password"])
page.click("button:has-text('Sign in')")
try:
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
except Exception as e:
print(f"\n[LOGIN FAIL] URL: {page.url}, title: {page.title()}, err: {e}")
raise
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
@pytest.fixture(scope="session")
+63
View File
@@ -0,0 +1,63 @@
from devplacepy.avatar import avatar_url, generate_avatar_svg
def test_avatar_url_format():
url = avatar_url("multiavatar", "testuser", 128)
assert url == "/avatar/multiavatar/testuser?size=128"
def test_avatar_url_different_size():
url = avatar_url("multiavatar", "testuser", 64)
assert "size=64" in url
def test_avatar_url_contains_seed():
url = avatar_url("multiavatar", "customuser", 128)
assert "customuser" in url
def test_generate_avatar_svg_returns_string():
svg = generate_avatar_svg("testuser")
assert isinstance(svg, str)
assert len(svg) > 100
def test_generate_avatar_svg_is_svg():
svg = generate_avatar_svg("testuser")
assert svg.strip().startswith("<svg")
assert "xmlns" in svg
def test_generate_avatar_svg_deterministic():
svg1 = generate_avatar_svg("testuser")
svg2 = generate_avatar_svg("testuser")
assert svg1 == svg2
def test_generate_avatar_svg_different_for_different_seeds():
svg1 = generate_avatar_svg("alice")
svg2 = generate_avatar_svg("bob")
assert svg1 != svg2
def test_generate_avatar_svg_fallback_on_exception():
svg = generate_avatar_svg("a")
assert isinstance(svg, str)
assert len(svg) > 50
def test_generate_avatar_svg_includes_rect_for_fallback():
svg = generate_avatar_svg("a")
assert "rect" in svg or "path" in svg
def test_avatar_url_with_different_seeds():
url1 = avatar_url("multiavatar", "alice", 128)
url2 = avatar_url("multiavatar", "bob", 128)
assert url1 != url2
def test_generate_avatar_svg_empty_seed_uses_fallback():
svg = generate_avatar_svg("")
assert isinstance(svg, str)
assert "rect" in svg or "svg" in svg
+16 -16
View File
@@ -3,7 +3,7 @@ from tests.conftest import BASE_URL
def create_post(page, content, topic="random", title=None):
page.goto(f"{BASE_URL}/feed")
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
page.locator(".feed-fab").first.click()
page.check(f"input[value='{topic}']")
@@ -15,7 +15,7 @@ def create_post(page, content, topic="random", title=None):
def signup(page, username, email, password):
page.goto(f"{BASE_URL}/auth/signup")
page.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
page.fill("#username", username)
page.fill("#email", email)
page.fill("#password", password)
@@ -35,7 +35,7 @@ def test_full_user_journey(browser, app_server):
# ═══════════════════════════════════════════════
# ACT 1 — LANDING PAGE
# ═══════════════════════════════════════════════
pa.goto(f"{BASE_URL}/")
pa.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
assert pa.is_visible("text=Devplace.net")
assert pa.is_visible("text=The Developer Social Network")
assert pa.is_visible("text=Join DevPlace Free")
@@ -58,7 +58,7 @@ def test_full_user_journey(browser, app_server):
# ACT 3 — ALICE EXPLORES FEED
# ═══════════════════════════════════════════════
pa.bring_to_front()
pa.goto(f"{BASE_URL}/feed")
pa.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
for tab_text in ("Topics", "All", "Trending", "Recent", "Following",
"Devlog", "Showcase", "Question", "Rant", "Fun"):
assert pa.is_visible(f"text={tab_text}")
@@ -95,7 +95,7 @@ def test_full_user_journey(browser, app_server):
# ACT 5 — ALICE CREATES TWO PROJECTS
# ═══════════════════════════════════════════════
pa.bring_to_front()
pa.goto(f"{BASE_URL}/projects")
pa.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
pa.locator("#create-project-btn").click()
pa.fill("#title", "N8Nme")
pa.fill("#description", "Visual workflow builder with 300+ integrations.")
@@ -124,7 +124,7 @@ def test_full_user_journey(browser, app_server):
# ACT 6 — ALICE EDITS HER PROFILE
# ═══════════════════════════════════════════════
pa.bring_to_front()
pa.goto(f"{BASE_URL}/profile/alice_demo")
pa.goto(f"{BASE_URL}/profile/alice_demo", wait_until="domcontentloaded")
assert pa.is_visible("text=alice_demo")
assert pa.is_visible("text=Member")
assert pa.is_visible("text=Posts")
@@ -146,11 +146,11 @@ def test_full_user_journey(browser, app_server):
assert pa.is_visible("text=https://github.com/alice_demo")
# Switch profile tabs
pa.goto(f"{BASE_URL}/profile/alice_demo?tab=projects")
pa.goto(f"{BASE_URL}/profile/alice_demo?tab=projects", wait_until="domcontentloaded")
pa.wait_for_timeout(300)
assert pa.is_visible("text=N8Nme")
assert pa.is_visible("text=DWN")
pa.goto(f"{BASE_URL}/profile/alice_demo?tab=posts")
pa.goto(f"{BASE_URL}/profile/alice_demo?tab=posts", wait_until="domcontentloaded")
pa.wait_for_timeout(300)
assert pa.is_visible("text=API Progress Update")
assert pa.is_visible("text=Pixel Editor WASM")
@@ -159,17 +159,17 @@ def test_full_user_journey(browser, app_server):
# ACT 7 — BOB EXPLORES ALICE'S STUFF
# ═══════════════════════════════════════════════
pb.bring_to_front()
pb.goto(f"{BASE_URL}/profile/alice_demo")
pb.goto(f"{BASE_URL}/profile/alice_demo", wait_until="domcontentloaded")
assert pb.is_visible("text=alice_demo")
assert pb.is_visible("text=Full-stack developer")
assert pb.is_visible("text=Berlin, Germany")
pb.goto(f"{BASE_URL}/profile/alice_demo?tab=projects")
pb.goto(f"{BASE_URL}/profile/alice_demo?tab=projects", wait_until="domcontentloaded")
assert pb.is_visible("text=N8Nme")
assert pb.is_visible("text=DWN")
# Browse projects, search
pb.goto(f"{BASE_URL}/projects")
pb.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
assert pb.is_visible("text=Recently Released")
assert pb.is_visible("text=N8Nme")
pb.fill("input[placeholder='Search projects...']", "N8Nme")
@@ -226,7 +226,7 @@ def test_full_user_journey(browser, app_server):
# ACT 10 — ALICE CHECKS NOTIFICATIONS
# ═══════════════════════════════════════════════
pa.bring_to_front()
pa.goto(f"{BASE_URL}/notifications")
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
assert pa.is_visible("h2:has-text('Notifications')")
mark_all = pa.locator("button:has-text('Mark all read')")
if mark_all.is_visible():
@@ -237,13 +237,13 @@ def test_full_user_journey(browser, app_server):
# ACT 11 — BOB CHECKS NOTIFICATIONS TOO
# ═══════════════════════════════════════════════
pb.bring_to_front()
pb.goto(f"{BASE_URL}/notifications")
pb.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
assert pb.is_visible("h2:has-text('Notifications')")
# ═══════════════════════════════════════════════
# ACT 12 — LOGOUT, LOGIN AGAIN
# ═══════════════════════════════════════════════
pa.goto(f"{BASE_URL}/auth/logout")
pa.goto(f"{BASE_URL}/auth/logout", wait_until="domcontentloaded")
pa.wait_for_url("**/", wait_until="domcontentloaded")
# Log back in
@@ -257,13 +257,13 @@ def test_full_user_journey(browser, app_server):
assert pa.is_visible("text=alice_demo")
# Bob logs out
pb.goto(f"{BASE_URL}/auth/logout")
pb.goto(f"{BASE_URL}/auth/logout", wait_until="domcontentloaded")
pb.wait_for_url("**/", wait_until="domcontentloaded")
# ═══════════════════════════════════════════════
# ACT 13 — FINAL VERIFICATION
# ═══════════════════════════════════════════════
pa.goto(f"{BASE_URL}/feed")
pa.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
assert pa.is_visible("text=Topics")
assert pa.is_visible("text=alice_demo")
+31 -4
View File
@@ -3,13 +3,13 @@ from tests.conftest import BASE_URL
def test_messages_page_loads(alice):
page, _ = alice
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")
page.goto(f"{BASE_URL}/messages")
assert page.is_visible(".messages-layout")
def test_messages_search_input(alice):
page, _ = alice
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")
page.goto(f"{BASE_URL}/messages")
search = page.locator("#message-search")
assert search.is_visible()
assert search.get_attribute("placeholder") == "Search conversations..."
@@ -17,7 +17,7 @@ def test_messages_search_input(alice):
def test_messages_empty_state(alice):
page, _ = alice
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")
page.goto(f"{BASE_URL}/messages")
empty = page.locator("text=No conversations yet")
if empty.is_visible():
pass
@@ -27,8 +27,35 @@ def test_messages_empty_state(alice):
def test_messages_search_for_bob(alice):
page, _ = alice
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")
page.goto(f"{BASE_URL}/messages")
page.fill("#message-search", "bob_test")
page.locator("#message-search").press("Enter")
page.wait_for_timeout(500)
assert page.is_visible(".messages-layout")
def test_messages_header_visible(alice):
page, _ = alice
page.goto(f"{BASE_URL}/messages")
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")
bell = page.locator(".topnav-icon").first
assert bell.is_visible()
def test_messages_topnav_user(alice):
page, user = alice
page.goto(f"{BASE_URL}/messages")
assert page.is_visible(f"text={user['username']}")
def test_messages_avatar_on_page(alice):
page, user = alice
page.goto(f"{BASE_URL}/messages")
avatar = page.locator("img.avatar-img").first
assert avatar.is_visible()
+35 -5
View File
@@ -3,13 +3,13 @@ from tests.conftest import BASE_URL
def test_notifications_page_loads(alice):
page, _ = alice
page.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
page.goto(f"{BASE_URL}/notifications")
assert page.is_visible("h2:has-text('Notifications')")
def test_mark_all_read(alice):
page, _ = alice
page.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
page.goto(f"{BASE_URL}/notifications")
mark_all = page.locator("button:has-text('Mark all read')")
if mark_all.is_visible():
mark_all.click()
@@ -18,13 +18,43 @@ def test_mark_all_read(alice):
def test_notifications_navigation(alice):
page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
page.goto(f"{BASE_URL}/feed")
page.goto(f"{BASE_URL}/notifications")
assert page.is_visible("h2:has-text('Notifications')")
def test_notifications_bell_visible(alice):
page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.goto(f"{BASE_URL}/feed")
bell = page.locator(".topnav-icon").first
assert bell.is_visible()
def test_notifications_empty_state(alice):
page, _ = alice
page.goto(f"{BASE_URL}/notifications")
empty = page.locator("text=No notifications yet")
if empty.is_visible():
pass
else:
assert page.is_visible("h2:has-text('Notifications')")
def test_notifications_header_has_mark_all(alice):
page, _ = alice
page.goto(f"{BASE_URL}/notifications")
mark_all = page.locator("button:has-text('Mark all read')")
assert mark_all.is_visible()
def test_notifications_topnav_user(alice):
page, user = alice
page.goto(f"{BASE_URL}/notifications")
assert page.is_visible(f"text={user['username']}")
def test_notifications_avatar_visible(alice):
page, _ = alice
page.goto(f"{BASE_URL}/notifications")
avatar = page.locator("img.avatar-img").first
assert avatar.is_visible()
-8
View File
@@ -3,14 +3,6 @@ from tests.conftest import BASE_URL
def create_post(page, topic="random", content="Test post content", title=None):
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
fab_count = page.locator(".feed-fab").count()
if fab_count == 0:
title = page.title()
has_fab = page.evaluate("document.body.innerHTML.includes('feed-fab')")
body_start = page.evaluate("document.body.innerHTML.substring(0, 500)")
print(f"\n[CREATE_POST] No FAB! URL={page.url} Title={title} has_fab={has_fab}")
print(f"[CREATE_POST] Body start: {body_start[:300]}")
page.wait_for_timeout(2000)
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
page.locator(".feed-fab").first.click()
page.check(f"input[value='{topic}']")
+82
View File
@@ -0,0 +1,82 @@
from devplacepy.utils import hash_password, verify_password, generate_uid, slugify, time_ago
from datetime import datetime, timedelta
def test_hash_password_returns_string():
result = hash_password("test123")
assert isinstance(result, str)
assert len(result) > 20
def test_verify_password_correct():
hashed = hash_password("correct-password")
assert verify_password("correct-password", hashed) is True
def test_verify_password_wrong():
hashed = hash_password("correct-password")
assert verify_password("wrong-password", hashed) is False
def test_generate_uid_is_unique():
uid1 = generate_uid()
uid2 = generate_uid()
assert uid1 != uid2
def test_generate_uid_format():
uid = generate_uid()
assert isinstance(uid, str)
assert len(uid) == 36 # UUID v4 format
def test_slugify_basic():
assert slugify("Hello World") == "hello-world"
def test_slugify_special_chars():
assert slugify("Hello! World???") == "hello-world"
def test_slugify_multiple_dashes():
assert slugify("hello---world") == "hello-world"
def test_slugify_leading_trailing():
assert slugify("--hello--") == "hello"
def test_time_ago_just_now():
now = datetime.utcnow().isoformat()
result = time_ago(now)
assert result == "just now"
def test_time_ago_minutes():
dt = (datetime.utcnow() - timedelta(minutes=5)).isoformat()
result = time_ago(dt)
assert "m ago" in result
def test_time_ago_hours():
dt = (datetime.utcnow() - timedelta(hours=3)).isoformat()
result = time_ago(dt)
assert "h ago" in result
def test_time_ago_days():
dt = (datetime.utcnow() - timedelta(days=5)).isoformat()
result = time_ago(dt)
assert "d ago" in result
def test_time_ago_months():
dt = (datetime.utcnow() - timedelta(days=60)).isoformat()
result = time_ago(dt)
assert "mo ago" in result
def test_time_ago_years():
dt = (datetime.utcnow() - timedelta(days=400)).isoformat()
result = time_ago(dt)
assert "y ago" in result