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:
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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())}"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 "")
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user