feat: replace raw form parsing with pydantic models for auth, admin, bugs, comments, and gists

Migrate all route handlers from manual `request.form()` parsing to typed `Annotated[Model, Form()]` dependencies using new pydantic models (SignupForm, LoginForm, ForgotPasswordForm, AdminRoleForm, AdminPasswordForm, BugForm, CommentForm, GistForm). Remove inline validation logic from signup, login, password reset, admin role/password change, bug creation, comment creation, and gist creation endpoints, delegating validation to model validators and field constraints. Add `RequestValidationError` exception handler with friendly error messages for auth form pages. Update model definitions to use `field_validator` and `model_validator` for cross-field checks like password matching.
This commit is contained in:
2026-05-23 03:44:04 +00:00
parent 26a2d3280f
commit 92911bedc9
23 changed files with 612 additions and 291 deletions
+56
View File
@@ -129,3 +129,59 @@ def test_admin_news_pagination(alice):
next_btn.click()
page.wait_for_url(f"{BASE_URL}/admin/news?page=2", wait_until="domcontentloaded")
assert "Page 2" in page.text_content(".pagination-info")
def _seed_target_user():
uid = str(uuid4())
get_table("users").insert({
"uid": uid, "username": f"target_{uid[:8]}", "email": f"target_{uid[:8]}@test.devplace",
"password_hash": "x", "role": "Member", "is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
})
return uid
def test_admin_settings_save(alice):
page, _ = alice
page.goto(f"{BASE_URL}/admin/settings", wait_until="domcontentloaded")
assert page.is_visible("#site_name")
newval = f"model-{uuid4().hex[:8]}"
page.fill("#news_ai_model", newval)
page.click("button:has-text('Save Settings')")
page.wait_for_url("**/admin/settings", wait_until="domcontentloaded")
assert page.locator("#news_ai_model").input_value() == newval
def test_admin_change_user_role(alice):
page, _ = alice
uid = _seed_target_user()
page.goto(f"{BASE_URL}/admin/users", wait_until="domcontentloaded")
sel = f"form[action='/admin/users/{uid}/role'] select[name='role']"
page.locator(sel).wait_for(state="visible")
page.select_option(sel, "admin")
page.wait_for_url("**/admin/users**", wait_until="domcontentloaded")
page.goto(f"{BASE_URL}/admin/users", wait_until="domcontentloaded")
assert page.locator(sel).input_value() == "admin"
def test_admin_toggle_user_active(alice):
page, _ = alice
uid = _seed_target_user()
page.goto(f"{BASE_URL}/admin/users", wait_until="domcontentloaded")
btn = f"form[action='/admin/users/{uid}/toggle'] button"
before = page.locator(btn).inner_text()
page.locator(btn).click()
page.wait_for_url("**/admin/users**", wait_until="domcontentloaded")
assert page.locator(btn).inner_text() != before
def test_admin_news_featured_toggle(alice):
page, _ = alice
seed_admin_news()
page.goto(f"{BASE_URL}/admin/news", wait_until="domcontentloaded")
toggle_sel = "form[action$='/toggle'] button.admin-toggle-switch"
was_active = "active" in (page.locator(toggle_sel).first.get_attribute("class") or "")
page.locator(toggle_sel).first.click()
page.wait_for_url(f"{BASE_URL}/admin/news", wait_until="domcontentloaded")
is_active = "active" in (page.locator(toggle_sel).first.get_attribute("class") or "")
assert is_active != was_active
+62
View File
@@ -1,4 +1,66 @@
import hashlib
import time
from datetime import datetime, timedelta, timezone
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.utils import hash_password, generate_uid
def test_forgot_password_page_loads(page, app_server):
page.goto(f"{BASE_URL}/auth/forgot-password", wait_until="domcontentloaded")
assert page.is_visible("input#email")
assert page.is_visible("button:has-text('Send Reset Link')")
def test_forgot_password_submit_shows_sent(page, app_server):
page.goto(f"{BASE_URL}/auth/forgot-password", wait_until="domcontentloaded")
page.fill("#email", "anybody@example.com")
page.click("button:has-text('Send Reset Link')")
page.locator(".auth-success").wait_for(state="visible")
def test_reset_password_page_loads(page, app_server):
page.goto(f"{BASE_URL}/auth/reset-password/sometoken", wait_until="domcontentloaded")
assert page.is_visible("input#password")
assert page.is_visible("input#confirm_password")
def test_reset_password_mismatch_shows_error(page, app_server):
page.goto(f"{BASE_URL}/auth/reset-password/sometoken", wait_until="domcontentloaded")
page.fill("#password", "abcdef1")
page.fill("#confirm_password", "different1")
page.click("button:has-text('Reset Password')")
page.locator(".auth-error").wait_for(state="visible")
def test_reset_password_full_flow(page, app_server):
uname = f"reset_{int(time.time() * 1000)}"
email = f"{uname}@t.dev"
uid = generate_uid()
get_table("users").insert({
"uid": uid, "username": uname, "email": email,
"password_hash": hash_password("oldpass123"),
"bio": "", "location": "", "git_link": "", "website": "",
"role": "Member", "is_active": True, "level": 1, "xp": 0, "stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
})
token = f"tok{uid[:8]}"
get_table("password_resets").insert({
"uid": generate_uid(), "user_uid": uid,
"token": hashlib.sha256(token.encode()).hexdigest(),
"expires_at": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(),
"used": False, "created_at": datetime.now(timezone.utc).isoformat(),
})
page.goto(f"{BASE_URL}/auth/reset-password/{token}", wait_until="domcontentloaded")
page.fill("#password", "newpass456")
page.fill("#confirm_password", "newpass456")
page.click("button:has-text('Reset Password')")
page.wait_for_url("**/auth/login", wait_until="domcontentloaded")
page.fill("#email", email)
page.fill("#password", "newpass456")
page.click("button:has-text('Sign in')")
page.wait_for_url("**/feed", wait_until="domcontentloaded")
assert "/feed" in page.url
def test_signup_page_loads(page, app_server):
+9
View File
@@ -1,4 +1,13 @@
import requests
from devplacepy.avatar import avatar_url, generate_avatar_svg
from tests.conftest import BASE_URL
def test_avatar_endpoint_serves_svg(app_server):
r = requests.get(f"{BASE_URL}/avatar/multiavatar/alice_test?size=64")
assert r.status_code == 200
assert "svg" in r.headers.get("content-type", "").lower()
assert "<svg" in r.text
def test_avatar_url_format():
+8
View File
@@ -0,0 +1,8 @@
import requests
from tests.conftest import BASE_URL
def test_404_renders_error_page(app_server):
r = requests.get(f"{BASE_URL}/this-page-does-not-exist-xyz")
assert r.status_code == 404
assert "404" in r.text or "not found" in r.text.lower()
+19
View File
@@ -0,0 +1,19 @@
from tests.conftest import BASE_URL
FOLLOW = "form[action='/follow/bob_test'] button"
UNFOLLOW = "form[action='/follow/unfollow/bob_test'] button"
def test_follow_then_unfollow(alice):
page, _ = alice
page.goto(f"{BASE_URL}/profile/bob_test", wait_until="domcontentloaded")
if page.is_visible(UNFOLLOW):
page.click(UNFOLLOW)
page.wait_for_url("**/profile/bob_test", wait_until="domcontentloaded")
assert page.is_visible(FOLLOW)
page.click(FOLLOW)
page.wait_for_url("**/profile/bob_test", wait_until="domcontentloaded")
assert page.is_visible(UNFOLLOW)
page.click(UNFOLLOW)
page.wait_for_url("**/profile/bob_test", wait_until="domcontentloaded")
assert page.is_visible(FOLLOW)
+9
View File
@@ -149,6 +149,15 @@ def test_gist_detail_share_button(alice, app_server):
assert_share_copies(page, "/gists/")
def test_gist_code_copy_button(alice, app_server):
from playwright.sync_api import expect
page, _ = alice
_create_gist(page, title=f"Copy Gist {int(time.time())}", source_code="copyme = 42")
btn = page.locator("button[data-copy='gist-code-content']")
btn.click()
expect(btn).to_have_text("Copied!", timeout=3000)
def test_profile_gists_tab(alice, app_server):
page, alice_user = alice
title = f"Profile Tab Test {int(time.time())}"
+13
View File
@@ -1,4 +1,17 @@
import time
from tests.conftest import BASE_URL
from devplacepy.database import get_table
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)}"
page.fill("input[name='content']", msg)
page.locator(".messages-send-btn").click()
page.wait_for_url("**/messages**", wait_until="domcontentloaded")
assert page.is_visible(f".message-bubble:has-text('{msg}')")
def test_messages_page_loads(alice):
+12
View File
@@ -0,0 +1,12 @@
from tests.conftest import BASE_URL
def test_mobile_hamburger_nav(alice):
page, _ = alice
page.set_viewport_size({"width": 390, "height": 844})
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
btn = page.locator("#hamburger-btn")
btn.wait_for(state="visible")
btn.click()
page.locator("#mobile-panel.open").wait_for(state="visible")
assert "open" in (page.locator("#mobile-panel").get_attribute("class") or "")
+49
View File
@@ -190,3 +190,52 @@ def test_post_across_all_topics(alice):
badge = page.locator(f".badge-{topic}")
assert badge.is_visible()
page.wait_for_timeout(200)
def test_delete_own_post(alice):
page, _ = alice
create_post(page, "random", "Post to be deleted permanently here")
post_url = page.url
page.once("dialog", lambda d: d.accept())
page.locator(".post-action-btn:has-text('Delete')").click()
page.wait_for_url(f"{BASE_URL}/feed", wait_until="domcontentloaded")
resp = page.goto(post_url, wait_until="domcontentloaded")
assert resp.status == 404
def test_content_rendering_markdown_and_highlight(alice):
page, _ = alice
create_post(page, "random", "Hello **world bold** text\n\n```python\nprint('hi')\n```")
page.locator(".rendered-content strong").first.wait_for(state="visible")
assert page.locator(".rendered-content pre code").count() >= 1
def test_mention_autocomplete(alice):
page, _ = alice
create_post(page, "random", "Post for mention autocomplete here")
ta = page.locator(".comment-form textarea[data-mention]").first
ta.click()
ta.press_sequentially("@bob")
item = page.locator(".mention-dropdown-item").first
item.wait_for(state="visible")
assert "bob" in item.inner_text().lower()
def test_emoji_picker_opens(alice):
page, _ = alice
create_post(page, "random", "Post for emoji picker here")
page.locator(".emoji-toggle-btn").first.click()
page.locator(".emoji-picker-wrapper").first.wait_for(state="visible")
assert page.locator("emoji-picker").first.is_visible()
def test_attachment_upload_ui(alice):
import io
from PIL import Image
page, _ = alice
create_post(page, "random", "Post for attachment upload UI")
buf = io.BytesIO()
Image.new("RGB", (4, 4), (0, 128, 255)).save(buf, "PNG")
file_input = page.locator(".comment-form .attachment-upload-container input[type='file']").first
file_input.set_input_files({"name": "pic.png", "mimeType": "image/png", "buffer": buf.getvalue()})
page.locator(".attachment-preview[data-uid]").first.wait_for(state="visible", timeout=15000)
+8
View File
@@ -1,6 +1,14 @@
from tests.conftest import BASE_URL
def test_profile_search_returns_results(alice):
page, _ = alice
resp = page.request.get(f"{BASE_URL}/profile/search?q=bob")
assert resp.status == 200
data = resp.json()
assert any("bob" in u["username"] for u in data["results"])
def test_profile_page_loads(alice):
page, user = alice
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
+35
View File
@@ -1,6 +1,41 @@
from tests.conftest import BASE_URL, assert_share_copies
def _create_project(page, title):
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.locator("#create-project-btn").click()
page.fill("#title", title)
page.fill("#description", "Project for testing")
page.fill("#release_date", "2026-06-01")
page.check("input[value='game']")
page.locator("#platforms-input").fill("PC")
page.locator("#platforms-input").press("Enter")
page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
def test_project_vote(alice):
page, _ = alice
_create_project(page, "Votable Project")
star = "form[action*='/votes/project/'] button"
before = int(page.locator(star).first.inner_text().strip(""))
page.locator(star).first.click()
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
after = int(page.locator(star).first.inner_text().strip(""))
assert after == before + 1
def test_delete_own_project(alice):
page, _ = alice
_create_project(page, "Deletable Project XYZ")
proj_url = page.url
page.once("dialog", lambda d: d.accept())
page.locator("form[action*='/projects/delete/'] button").click()
page.wait_for_url(f"{BASE_URL}/projects", wait_until="domcontentloaded")
resp = page.goto(proj_url, wait_until="domcontentloaded")
assert resp.status == 404
def test_project_detail_share_button(alice):
page, _ = alice
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")