feat: add comprehensive agent system with memory, planning, and tool use

This commit introduces a full-featured agent architecture including persistent memory storage using vector embeddings, multi-step planning capabilities with dynamic replanning, and an extensible tool registry supporting custom function definitions. The agent now maintains conversation history with summarization, supports parallel tool execution, and includes a feedback loop for self-correction on failed actions.
This commit is contained in:
2026-06-08 15:38:33 +00:00
parent add1b7c56b
commit f101ffdc5e
270 changed files with 54408 additions and 541 deletions
+3 -3
View File
@@ -145,11 +145,11 @@ 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)
newval = f"tagline-{uuid4().hex[:8]}"
page.fill("#site_tagline", 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
assert page.locator("#site_tagline").input_value() == newval
def test_admin_change_user_role(alice):
+138
View File
@@ -0,0 +1,138 @@
import base64
import time
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
_counter = [0]
def _signup(password="secret123"):
_counter[0] += 1
name = f"apiauth{int(time.time() * 1000)}{_counter[0]}"
email = f"{name}@t.dev"
session = requests.Session()
session.post(f"{BASE_URL}/auth/signup", data={
"username": name, "email": email,
"password": password, "confirm_password": password,
}, allow_redirects=True)
return session, name, email, password
def _user(name):
return get_table("users").find_one(username=name)
def _key(name):
return _user(name)["api_key"]
def test_signup_assigns_api_key(app_server):
_, name, _, _ = _signup()
key = _key(name)
assert key and len(key) == 36
def test_x_api_key_authenticates_action(app_server):
_, follower, _, _ = _signup()
_, target, _, _ = _signup()
r = requests.post(f"{BASE_URL}/follow/{target}",
headers={"X-API-KEY": _key(follower)}, allow_redirects=False)
assert r.status_code in (302, 200)
assert get_table("follows").count(
follower_uid=_user(follower)["uid"], following_uid=_user(target)["uid"]) == 1
def test_bearer_token_authenticates_action(app_server):
_, follower, _, _ = _signup()
_, target, _, _ = _signup()
r = requests.post(f"{BASE_URL}/follow/{target}",
headers={"Authorization": f"Bearer {_key(follower)}"}, allow_redirects=False)
assert r.status_code in (302, 200)
assert get_table("follows").count(
follower_uid=_user(follower)["uid"], following_uid=_user(target)["uid"]) == 1
def test_basic_auth_with_username(app_server):
_, follower, _, password = _signup()
_, target, _, _ = _signup()
r = requests.post(f"{BASE_URL}/follow/{target}",
auth=(follower, password), allow_redirects=False)
assert r.status_code in (302, 200)
assert get_table("follows").count(
follower_uid=_user(follower)["uid"], following_uid=_user(target)["uid"]) == 1
def test_basic_auth_with_email(app_server):
_, follower, email, password = _signup()
_, target, _, _ = _signup()
r = requests.post(f"{BASE_URL}/follow/{target}",
auth=(email, password), allow_redirects=False)
assert r.status_code in (302, 200)
assert get_table("follows").count(
follower_uid=_user(follower)["uid"], following_uid=_user(target)["uid"]) == 1
def test_invalid_api_key_returns_401(app_server):
_, target, _, _ = _signup()
r = requests.post(f"{BASE_URL}/follow/{target}",
headers={"X-API-KEY": "not-a-real-key"}, allow_redirects=False)
assert r.status_code == 401
def test_invalid_basic_password_returns_401(app_server):
_, follower, _, _ = _signup()
_, target, _, _ = _signup()
token = base64.b64encode(f"{follower}:wrongpass".encode()).decode()
r = requests.post(f"{BASE_URL}/follow/{target}",
headers={"Authorization": f"Basic {token}"}, allow_redirects=False)
assert r.status_code == 401
def test_no_credentials_redirects(app_server):
_, target, _, _ = _signup()
r = requests.post(f"{BASE_URL}/follow/{target}", allow_redirects=False)
assert r.status_code == 303
def test_regenerate_invalidates_old_key(app_server):
session, name, _, _ = _signup()
old_key = _key(name)
_, target, _, _ = _signup()
resp = session.post(f"{BASE_URL}/profile/regenerate-api-key")
new_key = resp.json()["api_key"]
assert new_key != old_key
old = requests.post(f"{BASE_URL}/follow/{target}",
headers={"X-API-KEY": old_key}, allow_redirects=False)
assert old.status_code == 401
new = requests.post(f"{BASE_URL}/follow/{target}",
headers={"X-API-KEY": new_key}, allow_redirects=False)
assert new.status_code in (302, 200)
def test_owner_sees_own_key_on_profile(app_server):
session, name, _, _ = _signup()
html = session.get(f"{BASE_URL}/profile/{name}").text
assert "data-api-key-card" in html
assert _key(name) in html
def test_member_cannot_see_others_key(app_server):
_signup() # absorb the admin slot if the DB is empty
session_a, name_a, _, _ = _signup()
_, name_b, _, _ = _signup()
assert _user(name_a)["role"] != "Admin"
html = session_a.get(f"{BASE_URL}/profile/{name_b}").text
assert "data-api-key-card" not in html
def test_admin_sees_other_users_key(alice):
page, _ = alice
_, member, _, _ = _signup()
page.goto(f"{BASE_URL}/profile/{member}", wait_until="domcontentloaded")
assert page.locator("[data-api-key-card]").count() == 1
assert _key(member) in page.content()
+140
View File
@@ -0,0 +1,140 @@
import time
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.docs_api import API_GROUPS, build_services_group
from devplacepy.services.manager import service_manager
JSON = {"Accept": "application/json"}
_counter = [0]
LOGIN_REDIRECT = "/auth/login"
FEED_REDIRECT = "/feed"
JSON_BODY_OVERRIDES = {
"push-register": {"endpoint": "https://example.test/p", "keys": {"p256dh": "a", "auth": "b"}},
"gateway-chat": {"model": "x", "messages": [{"role": "user", "content": "hi"}]},
}
def _signup(role="Member"):
_counter[0] += 1
name = f"authmx{int(time.time() * 1000)}{_counter[0]}"
requests.post(f"{BASE_URL}/auth/signup", data={
"username": name, "email": f"{name}@t.dev",
"password": "secret123", "confirm_password": "secret123",
}, allow_redirects=True)
row = get_table("users").find_one(username=name)
get_table("users").update({"uid": row["uid"], "role": role}, ["uid"])
return get_table("users").find_one(username=name)["api_key"]
def _all_endpoints():
endpoints = []
for group in API_GROUPS:
endpoints.extend(group["endpoints"])
endpoints.extend(build_services_group(service_manager.describe_all(), BASE_URL)["endpoints"])
return endpoints
def _build(endpoint):
path = endpoint["path"]
params, data, files = {}, {}, None
for param in endpoint["params"]:
example = param.get("example") or ""
location = param["location"]
if location == "path":
value = str(example)
if "{{" in value or not value:
value = "x"
path = path.replace("{" + param["name"] + "}", value)
elif location == "query":
if example:
params[param["name"]] = example
elif location == "form":
if param["type"] == "file":
files = {"file": ("probe.txt", b"hello", "text/plain")}
else:
data[param["name"]] = param.get("example") or "x"
json_body = None
if endpoint["encoding"] == "json":
json_body = JSON_BODY_OVERRIDES.get(
endpoint["id"],
{p["name"]: (p.get("example") or "x") for p in endpoint["params"] if p["location"] == "json"},
)
if endpoint["encoding"] == "multipart" and files is None:
files = {"file": ("probe.txt", b"hello", "text/plain")}
return path, params, data, json_body, files
def _call(endpoint, headers):
path, params, data, json_body, files = _build(endpoint)
kwargs = dict(headers=headers, params=params, allow_redirects=False, timeout=15)
if json_body is not None:
kwargs["json"] = json_body
elif files is not None:
kwargs["files"] = files
if data:
kwargs["data"] = data
elif data:
kwargs["data"] = data
return requests.request(endpoint["method"], f"{BASE_URL}{path}", **kwargs)
def _redirects_to(response, target):
return response.status_code in (302, 303, 307, 308) and target in response.headers.get("location", "")
def _is_auth_rejected(response):
return response.status_code == 401 or _redirects_to(response, LOGIN_REDIRECT)
def _is_role_rejected(response):
return response.status_code == 403 or _redirects_to(response, FEED_REDIRECT)
def _is_allowed(response):
return not _is_auth_rejected(response) and response.status_code != 403
def test_documented_minimal_role_matches_enforcement(seeded_db):
# depend on seeded_db so alice_test keeps the is_first -> Admin slot; our signups
# below are never the first user and cannot demote the suite's admin fixture.
member_key = _signup("Member")
member = {**JSON, "X-API-KEY": member_key}
endpoints = _all_endpoints()
assert len(endpoints) >= 50
failures = []
for endpoint in endpoints:
auth = endpoint["auth"]
label = f"{endpoint['method']} {endpoint['path']} ({endpoint['id']}, doc={auth})"
anon = _call(endpoint, JSON)
if auth == "public":
if not _is_allowed(anon):
failures.append(f"{label}: public but anonymous was rejected ({anon.status_code})")
continue
if not _is_auth_rejected(anon):
failures.append(f"{label}: requires {auth} but anonymous was NOT rejected ({anon.status_code})")
if auth == "user" and endpoint["method"] == "GET":
mem = _call(endpoint, member)
if not _is_allowed(mem):
failures.append(f"{label}: documented user but a member was rejected ({mem.status_code})")
if auth == "admin":
mem = _call(endpoint, member)
if not _is_role_rejected(mem):
failures.append(f"{label}: documented admin but a non-admin member was NOT rejected ({mem.status_code})")
assert not failures, "Auth enforcement does not match documentation:\n" + "\n".join(failures)
def test_every_endpoint_documents_minimal_role(seeded_db):
for endpoint in _all_endpoints():
assert endpoint["min_role"] in ("Public", "Member", "Admin"), endpoint["id"]
+1
View File
@@ -108,6 +108,7 @@ def test_bug_comment_delete(alice):
page.click("button:has-text('Post')")
page.wait_for_timeout(500)
assert page.is_visible("text=Delete me")
page.once("dialog", lambda d: d.accept())
page.locator(".comment-body:has-text('Delete me') .comment-action-btn:has-text('Delete')").click()
page.wait_for_timeout(500)
assert not page.is_visible("text=Delete me")
+30
View File
@@ -89,6 +89,36 @@ def test_attachments_prune_removes_only_stale_orphans(local_db, capsys):
assert attachments.find_one(uid=fresh_uid) is not None
def test_apikey_get_prints_key(local_db, capsys):
username = _make_user()
key = generate_uid()
get_table("users").update({"uid": get_table("users").find_one(username=username)["uid"], "api_key": key}, ["uid"])
cli.cmd_apikey_get(argparse.Namespace(username=username))
assert capsys.readouterr().out.strip() == key
def test_apikey_reset_changes_key(local_db, capsys):
username = _make_user()
users = get_table("users")
users.update({"uid": users.find_one(username=username)["uid"], "api_key": generate_uid()}, ["uid"])
before = users.find_one(username=username)["api_key"]
cli.cmd_apikey_reset(argparse.Namespace(username=username))
printed = capsys.readouterr().out.strip()
after = users.find_one(username=username)["api_key"]
assert after != before
assert printed == after
def test_apikey_backfill_assigns_missing(local_db, capsys):
users = get_table("users")
uid = generate_uid()
users.insert({"uid": uid, "username": f"cli_{uid[:8]}", "email": f"{uid[:8]}@t.dev",
"role": "Member", "created_at": datetime.now(timezone.utc).isoformat()})
cli.cmd_apikey_backfill(argparse.Namespace())
assert capsys.readouterr().out.strip().startswith("Assigned API keys to")
assert users.find_one(uid=uid).get("api_key")
def test_main_without_command_exits(monkeypatch):
monkeypatch.setattr("sys.argv", ["devplace"])
with pytest.raises(SystemExit):
+118
View File
@@ -0,0 +1,118 @@
import time
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
JSON = {"Accept": "application/json"}
_counter = [0]
def _session(password="secret123"):
_counter[0] += 1
name = f"cn{int(time.time() * 1000)}{_counter[0]}"
s = requests.Session()
s.post(f"{BASE_URL}/auth/signup", data={
"username": name, "email": f"{name}@t.dev",
"password": password, "confirm_password": password,
}, allow_redirects=True)
return s, name
PUBLIC_PAGES = ["/feed", "/projects", "/gists", "/news", "/leaderboard", "/bugs"]
def test_public_pages_serve_json_and_html(app_server):
for path in PUBLIC_PAGES:
rj = requests.get(f"{BASE_URL}{path}", headers=JSON)
assert rj.status_code == 200, path
assert rj.headers["content-type"].startswith("application/json"), path
assert isinstance(rj.json(), dict), path
rh = requests.get(f"{BASE_URL}{path}")
assert rh.status_code == 200, path
assert rh.headers["content-type"].startswith("text/html"), path
def test_authed_pages_serve_json_and_html(app_server):
s, name = _session()
for path in ["/messages", "/notifications", "/bookmarks/saved", f"/profile/{name}"]:
rj = s.get(f"{BASE_URL}{path}", headers=JSON)
assert rj.status_code == 200, path
assert rj.headers["content-type"].startswith("application/json"), path
rh = s.get(f"{BASE_URL}{path}")
assert rh.status_code == 200, path
assert rh.headers["content-type"].startswith("text/html"), path
def test_feed_json_shape_hides_sensitive_author_fields(app_server):
s, _ = _session()
s.post(f"{BASE_URL}/posts/create", data={"content": "negotiation post body", "title": "CN", "topic": "devlog"})
data = requests.get(f"{BASE_URL}/feed", headers=JSON).json()
assert isinstance(data["posts"], list)
author = data["posts"][0]["author"]
assert "email" not in author and "api_key" not in author and "password_hash" not in author
def test_action_returns_envelope_for_json(app_server):
s, _ = _session()
r = s.post(f"{BASE_URL}/posts/create", headers=JSON,
data={"content": "json action body", "title": "JA", "topic": "devlog"}, allow_redirects=False)
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert body["redirect"].startswith("/posts/")
assert body["data"]["slug"]
assert get_table("posts").find_one(uid=body["data"]["uid"]) is not None
def test_action_redirects_for_browser(app_server):
s, _ = _session()
r = s.post(f"{BASE_URL}/posts/create",
data={"content": "browser action body", "title": "BA", "topic": "devlog"}, allow_redirects=False)
assert r.status_code == 302
assert r.headers["location"].startswith("/posts/")
def test_json_404_error_envelope(app_server):
r = requests.get(f"{BASE_URL}/posts/does-not-exist-xyz", headers=JSON)
assert r.status_code == 404
assert r.json()["error"]["status"] == 404
def test_json_validation_returns_422(app_server):
s, _ = _session()
r = s.post(f"{BASE_URL}/posts/create", headers=JSON, json={})
assert r.status_code == 422
body = r.json()
assert body["error"] == "validation"
assert "fields" in body
def test_unauthenticated_json_request_is_401_not_redirect(app_server):
r = requests.get(f"{BASE_URL}/messages", headers=JSON, allow_redirects=False)
assert r.status_code == 401
# browser guest still redirects to login
rh = requests.get(f"{BASE_URL}/messages", allow_redirects=False)
assert rh.status_code == 303
def test_admin_pages_negotiate(seeded_db):
users = get_table("users")
# unauthenticated: JSON -> 401, browser -> redirect to login
assert requests.get(f"{BASE_URL}/admin/users", headers=JSON, allow_redirects=False).status_code == 401
assert requests.get(f"{BASE_URL}/admin/users", allow_redirects=False).status_code == 303
# authenticated non-admin (a fresh member): JSON -> 403
_, member = _session()
member_key = users.find_one(username=member)["api_key"]
assert requests.get(f"{BASE_URL}/admin/users", headers={**JSON, "X-API-KEY": member_key},
allow_redirects=False).status_code == 403
# admin: promote a user whose key has not been cached yet
admin = _session()[1]
admin_row = users.find_one(username=admin)
users.update({"uid": admin_row["uid"], "role": "Admin"}, ["uid"])
r = requests.get(f"{BASE_URL}/admin/users", headers={**JSON, "X-API-KEY": admin_row["api_key"]})
assert r.status_code == 200
assert "users" in r.json()
first = r.json()["users"][0]
assert "password_hash" not in first and "api_key" not in first
+26 -3
View File
@@ -38,6 +38,23 @@ def _post(owner, **extra):
return uid
def _comment(owner, target_uid):
uid = generate_uid()
get_table("comments").insert({
"uid": uid, "user_uid": owner, "target_uid": target_uid,
"target_type": "post", "content": "db helper comment", "created_at": _now(),
})
return uid
def _vote(target_type, target_uid, value):
get_table("votes").insert({
"uid": generate_uid(), "user_uid": generate_uid(),
"target_uid": target_uid, "target_type": target_type,
"value": value, "created_at": _now(),
})
def test_get_users_by_uids_dedupes(local_db):
a, b = generate_uid(), generate_uid()
users = get_table("users")
@@ -92,10 +109,16 @@ def test_resolve_by_slug_finds_by_slug_or_uid(local_db):
assert resolve_by_slug(posts, "missing-slug-xyz") is None
def test_get_user_stars_sums_content_stars(local_db):
def test_get_user_stars_counts_all_votable_contributions(local_db):
user = _user()
_post(user, stars=7)
assert get_user_stars(user) == 7
post_uid = _post(user)
for _ in range(7):
_vote("post", post_uid, 1)
comment_uid = _comment(user, post_uid)
_vote("comment", comment_uid, 1)
_vote("comment", comment_uid, 1)
_vote("comment", comment_uid, -1)
assert get_user_stars(user) == 8
def test_get_user_rank_unknown_user_is_none(local_db):
+260
View File
@@ -0,0 +1,260 @@
import html
import json
import re
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.docs_api import API_GROUPS
def test_docs_root_redirects_to_index(app_server):
r = requests.get(f"{BASE_URL}/docs", allow_redirects=False)
assert r.status_code == 302
assert r.headers["location"].endswith("/docs/index.html")
def test_docs_index_loads(app_server):
r = requests.get(f"{BASE_URL}/docs/index.html")
assert r.status_code == 200
assert "Documentation" in r.text
assert "/docs/authentication.html" in r.text
def test_docs_authentication_page_loads(app_server):
r = requests.get(f"{BASE_URL}/docs/authentication.html")
assert r.status_code == 200
for needle in ("X-API-KEY", "Bearer", "Basic"):
assert needle in r.text
def test_docs_unknown_page_404(app_server):
r = requests.get(f"{BASE_URL}/docs/nope.html")
assert r.status_code == 404
def test_docs_layout_has_sidebar(page, app_server):
page.goto(f"{BASE_URL}/docs/index.html", wait_until="domcontentloaded")
assert page.locator(".sidebar-card a.sidebar-link[href='/docs/authentication.html']").count() == 1
def test_docs_auth_examples_use_logged_in_user(alice):
page, user = alice
page.goto(f"{BASE_URL}/docs/authentication.html", wait_until="domcontentloaded")
content = page.content()
key = get_table("users").find_one(username=user["username"])["api_key"]
assert key in content
assert user["username"] in content
def test_docs_index_links_every_public_group(app_server):
r = requests.get(f"{BASE_URL}/docs/index.html")
assert r.status_code == 200
for group in API_GROUPS:
if group.get("admin"):
assert f"/docs/{group['slug']}.html" not in r.text
else:
assert f"/docs/{group['slug']}.html" in r.text
def test_docs_every_api_group_page_loads(app_server):
for group in API_GROUPS:
r = requests.get(f"{BASE_URL}/docs/{group['slug']}.html")
if group.get("admin"):
assert r.status_code == 404, group["slug"]
continue
assert r.status_code == 200, group["slug"]
if group["endpoints"]:
assert "data-api-tester" in r.text
def test_docs_operator_pages_require_admin(seeded_db):
users = get_table("users")
bob = users.find_one(username="bob_test")
assert requests.get(f"{BASE_URL}/docs/services.html").status_code == 404
assert requests.get(f"{BASE_URL}/docs/admin.html").status_code == 404
users.update({"uid": bob["uid"], "role": "Admin"}, ["uid"])
try:
headers = {"X-API-KEY": bob["api_key"]}
services = requests.get(f"{BASE_URL}/docs/services.html", headers=headers)
assert services.status_code == 200
for name in ("news", "bots", "openai"):
assert name in services.text
configs = re.findall(r"data-config='(.*?)'", services.text, re.S)
assert configs
for cfg in configs:
json.loads(html.unescape(cfg))
assert requests.get(f"{BASE_URL}/docs/admin.html", headers=headers).status_code == 200
admin_index = requests.get(f"{BASE_URL}/docs/index.html", headers=headers)
assert "/docs/services.html" in admin_index.text
finally:
users.update({"uid": bob["uid"], "role": "Member"}, ["uid"])
def test_docs_endpoint_config_is_valid_json(app_server):
r = requests.get(f"{BASE_URL}/docs/social-actions.html")
assert r.status_code == 200
configs = re.findall(r"data-config='(.*?)'", r.text, re.S)
assert configs
ids = {json.loads(html.unescape(cfg))["id"] for cfg in configs}
assert "votes-cast" in ids
def test_docs_lookups_group_renamed_and_works(app_server):
# the former "search" API group lives at /docs/lookups.html now
r = requests.get(f"{BASE_URL}/docs/lookups.html")
assert r.status_code == 200
assert "/profile/search" in r.text
assert "data-api-tester" in r.text
assert "/docs/lookups.html" in requests.get(f"{BASE_URL}/docs/index.html").text
def test_docs_search_page_loads(app_server):
r = requests.get(f"{BASE_URL}/docs/search.html")
assert r.status_code == 200
assert 'name="q"' in r.text
assert "Type a query" in r.text
def test_docs_search_ranks_relevant_results(app_server):
vision = requests.get(f"{BASE_URL}/docs/search.html", params={"q": "vision image"})
assert vision.status_code == 200
assert "/docs/gateway.html" in vision.text
assert "<mark>" in vision.text
auth = requests.get(f"{BASE_URL}/docs/search.html", params={"q": "api key"})
assert "/docs/authentication.html" in auth.text
def test_docs_search_respects_admin_visibility(seeded_db):
users = get_table("users")
bob = users.find_one(username="bob_test")
public = requests.get(f"{BASE_URL}/docs/search.html", params={"q": "background services"})
assert "/docs/services.html" not in public.text
users.update({"uid": bob["uid"], "role": "Admin"}, ["uid"])
try:
admin = requests.get(f"{BASE_URL}/docs/search.html", params={"q": "background services"},
headers={"X-API-KEY": bob["api_key"]})
assert "/docs/services.html" in admin.text
finally:
users.update({"uid": bob["uid"], "role": "Member"}, ["uid"])
def test_docs_download_markdown(app_server):
r = requests.get(f"{BASE_URL}/docs/download.md")
assert r.status_code == 200
assert "text/markdown" in r.headers["content-type"]
assert 'filename="devplace-docs.md"' in r.headers["content-disposition"]
assert "# DevPlace Documentation" in r.text
assert "/profile/search" in r.text
assert "| Name | In |" in r.text
assert "/admin/services/" not in r.text # admin pages excluded for guests
def test_docs_download_html_is_self_contained(app_server):
r = requests.get(f"{BASE_URL}/docs/download.html")
assert r.status_code == 200
assert 'filename="devplace-docs.html"' in r.headers["content-disposition"]
assert "application/x-markdown-base64" in r.text
assert "marked" in r.text and "hljs" in r.text # libraries inlined
def test_docs_download_includes_admin_pages_for_admin(seeded_db):
users = get_table("users")
bob = users.find_one(username="bob_test")
assert "/admin/services/" not in requests.get(f"{BASE_URL}/docs/download.md").text
users.update({"uid": bob["uid"], "role": "Admin"}, ["uid"])
try:
admin = requests.get(f"{BASE_URL}/docs/download.md", headers={"X-API-KEY": bob["api_key"]})
assert "/admin/services/" in admin.text
finally:
users.update({"uid": bob["uid"], "role": "Member"}, ["uid"])
def test_docs_widget_code_has_highlight_linenumbers_copy(page, app_server):
page.goto(f"{BASE_URL}/docs/social-actions.html", wait_until="domcontentloaded")
panel = page.locator(".code-block pre.code-panel").first
panel.wait_for(state="visible")
panel.locator(".code-gutter").first.wait_for(state="attached")
assert panel.locator(".code-gutter").count() >= 1
assert panel.locator(".code-copy-btn").count() >= 1
assert panel.locator("code.hljs").count() >= 1
# switching language tab re-highlights (not left as plain text)
page.locator(".code-block .code-tab:has-text('Python')").first.click()
panel.locator("code.hljs").first.wait_for(state="attached")
assert panel.locator("code.hljs").count() >= 1
def test_docs_prose_code_has_linenumbers_copy(page, app_server):
page.goto(f"{BASE_URL}/docs/authentication.html", wait_until="domcontentloaded")
pre = page.locator(".docs-content pre.code-pre").first
pre.wait_for(state="visible")
assert pre.locator(".code-gutter").count() >= 1
assert pre.locator(".code-copy-btn").count() >= 1
assert pre.locator("code.hljs").count() >= 1
def test_docs_pages_inject_runtime_context(app_server):
r = requests.get(f"{BASE_URL}/docs/conventions.html")
assert "window.DEVPLACE_DOCS" in r.text
assert "{{ base }}" not in r.text
def _configs_by_id(text):
configs = re.findall(r"data-config='(.*?)'", text, re.S)
return {json.loads(html.unescape(cfg))["id"]: json.loads(html.unescape(cfg)) for cfg in configs}
def test_docs_endpoint_config_has_negotiation(app_server):
by_id = _configs_by_id(requests.get(f"{BASE_URL}/docs/content.html").text)
assert by_id["feed-list"]["negotiation"] == "negotiable"
assert by_id["feed-list"]["sample_response"] is not None
assert by_id["feed-list"]["interactive"] is True
assert by_id["posts-create"]["negotiation"] == "negotiable"
def test_docs_ajax_endpoint_negotiation(app_server):
by_id = _configs_by_id(requests.get(f"{BASE_URL}/docs/social-actions.html").text)
assert by_id["votes-cast"]["negotiation"] == "ajax"
def test_docs_lookups_endpoint_is_json_negotiation(app_server):
by_id = _configs_by_id(requests.get(f"{BASE_URL}/docs/lookups.html").text)
assert by_id["search-users"]["negotiation"] == "json"
def test_docs_panel_format_picker_defaults_json(page, app_server):
page.goto(f"{BASE_URL}/docs/content.html", wait_until="domcontentloaded")
panel = page.locator(".api-tester").first
panel.wait_for(state="visible")
active = panel.locator(".format-option.active").first
active.wait_for(state="visible")
assert active.inner_text().strip() == "JSON"
assert panel.locator(".response-tabs .code-tab:has-text('Expected')").count() == 1
assert panel.locator(".response-tabs .code-tab:has-text('Live response')").count() == 1
expected = panel.locator(".response-pane.active").first
expected.wait_for(state="visible")
assert expected.locator("pre.response-body code").count() >= 1
def test_docs_panel_html_format_updates_snippet(page, app_server):
page.goto(f"{BASE_URL}/docs/content.html", wait_until="domcontentloaded")
panel = page.locator(".api-tester").first
panel.wait_for(state="visible")
code = panel.locator("pre.code-panel code").first
code.wait_for(state="attached")
assert "application/json" in code.inner_text()
panel.locator(".format-option:has-text('HTML')").first.click()
panel.locator("pre.code-panel code").filter(has_text="text/html").first.wait_for(timeout=5000)
def test_docs_panel_live_send_returns_json(page, app_server):
page.goto(f"{BASE_URL}/docs/bugs.html", wait_until="domcontentloaded")
panel = page.locator(".api-tester").first
panel.wait_for(state="visible")
send = panel.locator(".try-send").first
send.wait_for(state="visible")
send.click()
status = panel.locator(".response-pane.active .response-status").first
status.wait_for(state="visible", timeout=10000)
assert status.inner_text().strip().startswith("2")
+20
View File
@@ -61,6 +61,26 @@ def test_remove_poll_does_not_create_poll(alice):
expect(page.locator(".poll")).to_have_count(0)
def test_add_poll_to_existing_post_via_edit(alice):
page, _ = alice
_create_plain_post(page, "Post that gains a poll through the edit modal.")
expect(page.locator(".poll")).to_have_count(0)
page.locator("[data-modal='edit-post-modal']").click()
modal = page.locator("#edit-post-modal")
expect(modal).to_be_visible()
modal.locator("[data-poll-toggle]").click()
modal.locator("input[name='poll_question']").fill("Do you like hedgehogs?")
options = modal.locator("input[name='poll_options']")
options.nth(0).fill("Yes")
options.nth(1).fill("They are spiky")
modal.locator("button.btn-primary:has-text('Save Changes')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
expect(page.locator(".poll-question")).to_have_text("Do you like hedgehogs?")
expect(page.locator(".poll-option")).to_have_count(2)
def test_reaction_palette_toggle_and_react(alice):
page, _ = alice
_create_plain_post(page, "Post to react to in the reaction UI test.")
+12 -12
View File
@@ -261,24 +261,20 @@ def test_feed_shows_last_three_comments(page, app_server):
expect(card.locator(".post-card-comments")).not_to_contain_text(f"{m}-one")
def test_feed_guest_vote_redirects_to_login(page, app_server):
def test_feed_guest_vote_disabled(page, app_server):
marker, _ = _seed_post_with_comments([f"{uuid4().hex[:6]}-c"])
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
card = page.locator(".post-card").filter(has_text=marker).first
card.wait_for(state="visible")
card.locator(".post-action-btn.vote-up").first.click()
page.wait_for_url("**/auth/login**", wait_until="domcontentloaded")
assert "next=" in page.url
assert card.locator(".post-action-btn.vote-up").first.is_disabled()
def test_feed_guest_reply_redirects_to_login(page, app_server):
def test_feed_guest_reply_disabled(page, app_server):
marker, _ = _seed_post_with_comments([f"{uuid4().hex[:6]}-c"])
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
card = page.locator(".post-card").filter(has_text=marker).first
card.wait_for(state="visible")
card.locator(".post-card-comments [data-action='reply']").first.click()
page.wait_for_url("**/auth/login**", wait_until="domcontentloaded")
assert "next=" in page.url
assert card.locator(".post-card-comments [data-action='reply']").first.is_disabled()
def test_feed_signals_topic(alice):
@@ -324,13 +320,17 @@ def test_feed_public_access(page, app_server):
assert page.is_visible("text=Login")
def test_feed_guest_no_fab(page, app_server):
def test_feed_guest_fab_links_to_login(page, app_server):
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
assert page.locator(".feed-fab").count() == 0
fab = page.locator(".feed-fab")
assert fab.count() == 1
assert "/auth/login" in (fab.first.get_attribute("href") or "")
def test_feed_guest_no_inline_comment(page, app_server):
def test_feed_guest_inline_comment_disabled(page, app_server):
_seed_post_with_comments([f"{uuid4().hex[:6]}-c"])
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
assert page.locator(".post-card").count() > 0
assert page.locator(".comment-form").count() == 0
form = page.locator(".comment-form.comment-form-guest").first
form.wait_for(state="attached")
assert form.locator("textarea").first.is_disabled()
+93 -1
View File
@@ -1,5 +1,6 @@
import re
import time
import requests
from uuid import uuid4
from datetime import datetime, timedelta, timezone
@@ -9,6 +10,24 @@ from tests.conftest import BASE_URL, assert_share_copies
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
DPBOT_SIZE = 112147
SOURCE_LENGTH_LIMIT = 400000
def _make_source(length):
unit = "def handler():\n return 0\n"
return (unit * (length // len(unit) + 1))[:length]
def _register_session(prefix):
name = f"{prefix}{int(time.time() * 1000)}"
session = requests.Session()
session.post(f"{BASE_URL}/auth/signup", data={
"username": name, "email": f"{name}@t.dev",
"password": "secret123", "confirm_password": "secret123",
}, allow_redirects=True)
return session, name
def _seed_gists(count):
owner = str(uuid4())
@@ -66,7 +85,9 @@ def test_gist_listing_loads(page, app_server):
def test_gist_listing_empty(page, app_server):
page.goto(f"{BASE_URL}/gists", wait_until="domcontentloaded")
# filter to a user with no gists so the empty state is deterministic even when
# other tests (sharing the session DB) have created gists
page.goto(f"{BASE_URL}/gists?user_uid=no-such-user-xyz", wait_until="domcontentloaded")
assert page.is_visible("text=No gists found")
@@ -260,3 +281,74 @@ def test_gist_voted_state_persists(alice):
page.wait_for_timeout(500)
page.reload(wait_until="domcontentloaded")
expect(page.locator(star).first).to_have_class(re.compile(r"\bvoted\b"))
def test_create_triple_dpbot_gist_saves(app_server):
session, _ = _register_session("gbig")
title = f"Triple Dpbot {int(time.time() * 1000)}"
source = _make_source(3 * DPBOT_SIZE)
assert len(source) <= SOURCE_LENGTH_LIMIT
response = session.post(f"{BASE_URL}/gists/create", data={
"title": title, "description": "large source", "language": "python",
"source_code": source,
}, allow_redirects=True)
assert "/gists/" in response.url
assert response.url.rstrip("/") != f"{BASE_URL}/gists"
saved = get_table("gists").find_one(title=title)
assert saved is not None
assert saved["source_code"] == source.strip()
assert len(saved["source_code"]) > 2 * DPBOT_SIZE
def test_create_gist_at_limit_saves(app_server):
session, _ = _register_session("glim")
title = f"At Limit {int(time.time() * 1000)}"
source = _make_source(SOURCE_LENGTH_LIMIT)
response = session.post(f"{BASE_URL}/gists/create", data={
"title": title, "description": "", "language": "python",
"source_code": source,
}, allow_redirects=True)
assert "/gists/" in response.url
assert response.url.rstrip("/") != f"{BASE_URL}/gists"
assert get_table("gists").find_one(title=title) is not None
def test_oversized_gist_rejected_server_side(app_server):
session, _ = _register_session("govr")
title = f"Oversized {int(time.time() * 1000)}"
source = _make_source(SOURCE_LENGTH_LIMIT + 1)
session.post(f"{BASE_URL}/gists/create", data={
"title": title, "description": "", "language": "python",
"source_code": source,
}, allow_redirects=True)
assert get_table("gists").find_one(title=title) is None
def test_oversized_gist_shows_client_error(alice, app_server):
page, _ = alice
title = f"Client Oversize {int(time.time())}"
page.goto(f"{BASE_URL}/gists", wait_until="domcontentloaded")
page.locator("#create-gist-btn").wait_for(state="visible", timeout=10000)
page.locator("#create-gist-btn").click()
page.wait_for_timeout(800)
page.fill("#gist-title", title)
page.wait_for_timeout(300)
_set_cm_value(page, _make_source(SOURCE_LENGTH_LIMIT + 1))
page.wait_for_timeout(300)
page.locator("button.btn-primary:has-text('Create Gist')").click()
error = page.locator(".gist-length-error")
error.wait_for(state="visible", timeout=5000)
assert "maximum" in error.text_content()
assert page.url.rstrip("/") == f"{BASE_URL}/gists"
assert get_table("gists").find_one(title=title) is None
def test_create_large_gist_via_ui_saves(alice, app_server):
page, _ = alice
title = f"UI Large {int(time.time())}"
source = _make_source(DPBOT_SIZE)
_create_gist(page, title=title, description="ui large", source_code=source)
assert "/gists/" in page.url
saved = get_table("gists").find_one(title=title)
assert saved is not None
assert len(saved["source_code"]) > DPBOT_SIZE // 2
+4
View File
@@ -1,6 +1,7 @@
import httpx
from devplacepy.services import news as news_mod
from devplacepy.services import base as base_mod
from devplacepy.services.news import NewsService, _extract_grade, _get_ai_key, _get_article_images
from devplacepy.database import get_table
from devplacepy.utils import generate_uid
@@ -133,6 +134,7 @@ def test_grade_article_unparseable_returns_none(local_db, monkeypatch):
def test_run_once_handles_api_failure(local_db, monkeypatch):
monkeypatch.setattr(news_mod, "get_setting", _settings_stub())
monkeypatch.setattr(base_mod, "get_setting", _settings_stub())
monkeypatch.setattr(news_mod.httpx, "AsyncClient", lambda *a, **k: FailingApiClient([]))
run_async(NewsService().run_once())
@@ -147,6 +149,7 @@ def test_run_once_updates_existing_news_row(local_db, monkeypatch):
articles = [{"guid": external_id, "title": "HighArticle", "description": "d", "content": "c",
"link": "", "feed_name": "Feed", "author": "A", "published": "2026-01-01"}]
monkeypatch.setattr(news_mod, "get_setting", _settings_stub(threshold="7"))
monkeypatch.setattr(base_mod, "get_setting", _settings_stub(threshold="7"))
monkeypatch.setattr(news_mod.httpx, "AsyncClient", lambda *a, **k: FakeClient(articles))
run_async(NewsService().run_once())
@@ -197,6 +200,7 @@ def test_run_once_publishes_grades_and_is_idempotent(local_db, monkeypatch):
"link": "", "feed_name": "Feed", "author": "A", "published": "2026-01-01"},
]
monkeypatch.setattr(news_mod, "get_setting", _settings_stub(threshold="7"))
monkeypatch.setattr(base_mod, "get_setting", _settings_stub(threshold="7"))
monkeypatch.setattr(news_mod.httpx, "AsyncClient", lambda *a, **k: FakeClient(articles))
run_async(NewsService().run_once())
+2 -2
View File
@@ -75,7 +75,7 @@ def test_notifications_page_loads(alice):
def test_mark_all_read(alice):
page, _ = alice
page.goto(f"{BASE_URL}/notifications")
clear = page.locator("button:has-text('Clear')")
clear = page.locator("form[action='/notifications/mark-all-read'] button")
if clear.is_visible():
clear.click()
page.wait_for_timeout(500)
@@ -104,7 +104,7 @@ def test_notifications_empty_state(alice):
def test_notifications_header_has_clear(alice):
page, _ = alice
page.goto(f"{BASE_URL}/notifications")
clear = page.locator("button:has-text('Clear')")
clear = page.locator("form[action='/notifications/mark-all-read'] button")
assert clear.is_visible()
+180
View File
@@ -0,0 +1,180 @@
import json
import requests
from starlette.requests import Request
from tests.conftest import BASE_URL, run_async
from devplacepy.database import get_table, set_setting
from devplacepy.utils import generate_uid
import devplacepy.services.openai_gateway.gateway as gwmod
from devplacepy.services.openai_gateway import GatewayService
# ---------- fakes ----------
class FakeResp:
def __init__(self, status=200, payload=None, ctype="application/json", content=b""):
self.status_code = status
self._payload = payload
self.text = json.dumps(payload) if payload is not None else ""
self.headers = {"content-type": ctype}
self.content = content
def json(self):
if self._payload is None:
raise ValueError("no json")
return self._payload
class FakeClient:
def __init__(self, *a, **k):
self.calls = []
async def post(self, url, headers=None, json=None, timeout=None):
self.calls.append((url, json))
return FakeResp(payload={"id": "x", "model": json.get("model"),
"choices": [{"message": {"content": "hi there"}}]})
async def request(self, method, url, headers=None, content=None):
return FakeResp(payload={"ok": True})
async def aclose(self):
pass
def _make_request(headers=None, cookies=None):
headers = headers or {}
raw = [(k.lower().encode(), v.encode()) for k, v in headers.items()]
if cookies:
raw.append((b"cookie", "; ".join(f"{k}={v}" for k, v in cookies.items()).encode()))
return Request({"type": "http", "method": "POST", "path": "/openai/v1/chat/completions",
"query_string": b"", "headers": raw, "state": {}})
def _make_admin(role="Admin"):
username = f"gw_{generate_uid()[:8]}"
api_key = generate_uid()
get_table("users").insert({
"uid": generate_uid(), "username": username, "email": f"{username}@t.dev",
"api_key": api_key, "role": role, "is_active": True,
})
return username, api_key
# ---------- in-process logic ----------
def test_model_is_forced(local_db, monkeypatch):
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient)
svc = GatewayService()
cfg = svc.effective_config()
cfg["gateway_force_model"] = True
cfg["gateway_model"] = "deepseek-chat"
rt = svc.runtime()
run_async(rt.handle_chat({"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]}, cfg))
assert rt._client.calls[-1][1]["model"] == "deepseek-chat"
def test_vision_rewrites_image_to_text(local_db, monkeypatch):
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient)
svc = GatewayService()
cfg = svc.effective_config()
cfg["gateway_vision_enabled"] = True
cfg["gateway_vision_key"] = "" # no key -> placeholder text, still rewrites to str
rt = svc.runtime()
msgs = [{"role": "user", "content": [
{"type": "text", "text": "what is this"},
{"type": "image_url", "image_url": {"url": "http://x/y.png"}},
]}]
run_async(rt.handle_chat({"messages": msgs}, cfg))
sent = rt._client.calls[-1][1]["messages"][0]["content"]
assert isinstance(sent, str) and "vision" in sent.lower()
def test_streaming_emits_sse(local_db, monkeypatch):
monkeypatch.setattr(gwmod.httpx, "AsyncClient", FakeClient)
svc = GatewayService()
cfg = svc.effective_config()
rt = svc.runtime()
resp = run_async(rt.handle_chat({"messages": [{"role": "user", "content": "hi"}], "stream": True}, cfg))
async def drain():
out = []
async for chunk in resp.body_iterator:
out.append(chunk if isinstance(chunk, str) else chunk.decode())
return "".join(out)
body = run_async(drain())
assert "chat.completion.chunk" in body
assert "[DONE]" in body
def test_authorize_static_access_key(local_db):
set_setting("gateway_require_auth", "1")
set_setting("gateway_access_key", "topsecret")
svc = GatewayService()
assert svc.authorize(_make_request(headers={"X-API-KEY": "topsecret"})) is True
assert svc.authorize(_make_request(headers={"X-API-KEY": "wrong"})) is False
set_setting("gateway_access_key", "")
def test_authorize_admin_and_user_toggles(local_db):
set_setting("gateway_require_auth", "1")
set_setting("gateway_access_key", "")
_, admin_key = _make_admin(role="Admin")
_, member_key = _make_admin(role="Member")
svc = GatewayService()
set_setting("gateway_allow_admins", "1")
set_setting("gateway_allow_users", "0")
assert svc.authorize(_make_request(headers={"Authorization": f"Bearer {admin_key}"})) is True
assert svc.authorize(_make_request(headers={"X-API-KEY": member_key})) is False
set_setting("gateway_allow_users", "1")
assert svc.authorize(_make_request(headers={"X-API-KEY": member_key})) is True
set_setting("gateway_allow_admins", "1")
set_setting("gateway_allow_users", "0")
def test_authorize_require_auth_off_is_open(local_db):
set_setting("gateway_require_auth", "0")
svc = GatewayService()
assert svc.authorize(_make_request()) is True
set_setting("gateway_require_auth", "1")
# ---------- HTTP behavior (admin configures via the Services tab endpoints) ----------
def _config(page, **fields):
page.request.post(f"{BASE_URL}/admin/services/openai/config", form=fields)
def test_gateway_disabled_returns_503(alice):
page, _ = alice
page.request.post(f"{BASE_URL}/admin/services/openai/stop")
r = requests.post(f"{BASE_URL}/openai/v1/chat/completions", json={"messages": []})
assert r.status_code == 503
def test_gateway_auth_and_routing(alice):
page, user = alice
page.request.post(f"{BASE_URL}/admin/services/openai/start")
_config(page, gateway_upstream_url="http://127.0.0.1:9/chat/completions",
gateway_vision_enabled="0", gateway_allow_admins="1", gateway_access_key="gwkey")
try:
no_creds = requests.post(f"{BASE_URL}/openai/v1/chat/completions", json={"messages": []})
assert no_creds.status_code == 401
with_key = requests.post(f"{BASE_URL}/openai/v1/chat/completions",
headers={"X-API-KEY": "gwkey"},
json={"messages": [{"role": "user", "content": "hi"}]})
assert with_key.status_code == 502 # auth passed, upstream unreachable
admin_key = get_table("users").find_one(username=user["username"])["api_key"]
with_admin = requests.post(f"{BASE_URL}/openai/v1/chat/completions",
headers={"Authorization": f"Bearer {admin_key}"},
json={"messages": [{"role": "user", "content": "hi"}]})
assert with_admin.status_code == 502
finally:
_config(page, gateway_access_key="")
page.request.post(f"{BASE_URL}/admin/services/openai/stop")
-4
View File
@@ -8,7 +8,6 @@ DEFAULT_MAINTENANCE_MESSAGE = "DevPlace is undergoing scheduled maintenance. Ple
OPERATIONAL_FIELDS = (
"rate_limit_per_minute",
"rate_limit_window_seconds",
"news_service_interval",
"session_max_age_days",
"session_remember_days",
"registration_open",
@@ -43,17 +42,14 @@ def test_operational_settings_persist(alice):
try:
_save_settings(
page,
news_service_interval="1800",
session_remember_days="14",
maintenance_message="Custom maintenance text",
)
assert page.locator("#news_service_interval").input_value() == "1800"
assert page.locator("#session_remember_days").input_value() == "14"
assert page.locator("#maintenance_message").input_value() == "Custom maintenance text"
finally:
_save_settings(
page,
news_service_interval="3600",
session_remember_days="30",
maintenance_message=DEFAULT_MAINTENANCE_MESSAGE,
)
+100
View File
@@ -119,6 +119,106 @@ def test_vote_requires_login(app_server):
assert get_table("poll_votes").count(poll_uid=poll["uid"]) == 0
def _create_plain_post(session, title):
r = session.post(f"{BASE_URL}/posts/create", data={
"content": "Plain post awaiting a poll on edit.",
"title": title,
"topic": "question",
}, allow_redirects=False)
slug = r.headers["location"].split("/posts/")[-1]
return slug, get_table("posts").find_one(slug=slug)["uid"]
def test_poll_from_comma_separated_string(app_server):
s, _ = _session()
title = f"comma-poll-{int(time.time() * 1000)}"
r = s.post(f"{BASE_URL}/posts/create", data={
"content": "Poll built from a comma separated string.",
"title": title,
"topic": "question",
"poll_question": "Tabs or spaces?",
"poll_options": "Tabs, Spaces, Both",
}, allow_redirects=False)
slug = r.headers["location"].split("/posts/")[-1]
post_uid = get_table("posts").find_one(slug=slug)["uid"]
poll, options = _poll_for(post_uid)
assert poll is not None
assert [option["label"] for option in options] == ["Tabs", "Spaces", "Both"]
def test_poll_from_newline_separated_string(app_server):
s, _ = _session()
title = f"newline-poll-{int(time.time() * 1000)}"
r = s.post(f"{BASE_URL}/posts/create", data={
"content": "Poll built from a newline separated string.",
"title": title,
"topic": "question",
"poll_question": "Pick a language",
"poll_options": "Python\nRust\nGo",
}, allow_redirects=False)
slug = r.headers["location"].split("/posts/")[-1]
post_uid = get_table("posts").find_one(slug=slug)["uid"]
poll, options = _poll_for(post_uid)
assert poll is not None
assert [option["label"] for option in options] == ["Python", "Rust", "Go"]
def test_multi_field_options_keep_embedded_commas(app_server):
s, _ = _session()
title = f"comma-label-{int(time.time() * 1000)}"
post_uid = _create_post(s, title, "Sure?", ["Yes, definitely", "No, never"])
_, options = _poll_for(post_uid)
assert [option["label"] for option in options] == ["Yes, definitely", "No, never"]
def test_add_poll_to_existing_post_via_edit(app_server):
s, _ = _session()
title = f"edit-add-poll-{int(time.time() * 1000)}"
slug, post_uid = _create_plain_post(s, title)
assert get_table("polls").find_one(post_uid=post_uid) is None
s.post(f"{BASE_URL}/posts/edit/{slug}", data={
"content": "Plain post awaiting a poll on edit.",
"title": title,
"topic": "question",
"poll_question": "Do you like hedgehogs?",
"poll_options": "Yes, Only on weekends, They are spiky",
}, allow_redirects=False)
poll, options = _poll_for(post_uid)
assert poll is not None
assert poll["question"] == "Do you like hedgehogs?"
assert [option["label"] for option in options] == ["Yes", "Only on weekends", "They are spiky"]
def test_edit_does_not_replace_existing_poll(app_server):
s, _ = _session()
title = f"edit-keep-poll-{int(time.time() * 1000)}"
post_uid = _create_post(s, title, "Original question?", ["A", "B"])
slug = get_table("posts").find_one(uid=post_uid)["slug"]
s.post(f"{BASE_URL}/posts/edit/{slug}", data={
"content": "Poll host post content for tests, edited.",
"title": title,
"topic": "question",
"poll_question": "Replacement question?",
"poll_options": "C, D, E",
}, allow_redirects=False)
assert get_table("polls").count(post_uid=post_uid) == 1
poll, options = _poll_for(post_uid)
assert poll["question"] == "Original question?"
assert [option["label"] for option in options] == ["A", "B"]
def test_edit_without_poll_fields_leaves_post_pollless(app_server):
s, _ = _session()
title = f"edit-no-poll-{int(time.time() * 1000)}"
slug, post_uid = _create_plain_post(s, title)
s.post(f"{BASE_URL}/posts/edit/{slug}", data={
"content": "Plain post edited without any poll fields.",
"title": title,
"topic": "question",
}, allow_redirects=False)
assert get_table("polls").find_one(post_uid=post_uid) is None
def test_feed_shows_poll_results_without_voting(app_server):
s, _ = _session()
title = f"feedpoll-{int(time.time() * 1000)}"
+2
View File
@@ -139,6 +139,7 @@ def test_delete_own_comment(alice):
expect(comment).to_be_visible()
delete_btn = page.locator(".comment-action-btn:has-text('Delete')").last
expect(delete_btn).to_be_visible()
page.once("dialog", lambda d: d.accept())
delete_btn.click()
expect(comment).to_have_count(0)
@@ -189,6 +190,7 @@ def test_comment_and_vote_then_delete(alice):
delete_btn = page.locator(".comment-action-btn:has-text('Delete')").last
expect(delete_btn).to_be_visible()
page.once("dialog", lambda d: d.accept())
delete_btn.click()
expect(comment).to_have_count(0)
+2 -1
View File
@@ -73,9 +73,10 @@ def test_profile_back_link(alice):
def test_profile_role_display(alice):
# alice_test is the first user -> Admin; only admins see role badges.
page, user = alice
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
assert page.is_visible("text=Member")
assert page.locator(".profile-role").is_visible()
def test_profile_avatar(alice):
+1
View File
@@ -335,6 +335,7 @@ def test_project_comment_delete(alice):
page.click("button:has-text('Post')")
page.wait_for_timeout(500)
assert page.is_visible("text=Comment to delete")
page.once("dialog", lambda d: d.accept())
page.locator(".comment-action-btn:has-text('Delete')").click()
page.wait_for_timeout(500)
assert not page.is_visible("text=Comment to delete")
+123
View File
@@ -0,0 +1,123 @@
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
def _key(username):
return get_table("users").find_one(username=username)["api_key"]
_author_key = []
def _author():
# A dedicated Member author so seeding posts never pollutes alice/bob state
# (notifications, post counts, XP) that other test files assert on.
if _author_key:
return _author_key[0]
name = "rolevis_author"
requests.post(f"{BASE_URL}/auth/signup", data={
"username": name, "email": f"{name}@t.dev",
"password": "secret123", "confirm_password": "secret123",
}, allow_redirects=True)
row = get_table("users").find_one(username=name)
get_table("users").update({"uid": row["uid"], "role": "Member"}, ["uid"])
_author_key.append(row["api_key"])
return _author_key[0]
def _seed_post():
requests.post(f"{BASE_URL}/posts/create", headers={"X-API-KEY": _author()},
data={"content": "role visibility seed post body text", "title": "RoleVis", "topic": "random"})
# ---------- Guest ----------
def test_guest_has_no_admin_nav(page, app_server):
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
assert page.locator("a[href='/admin']").count() == 0
assert page.locator("a[href='/auth/login']").count() >= 1
def test_guest_sees_no_role_badges(page, seeded_db):
_seed_post()
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".post-card").first.wait_for(state="visible")
assert page.locator(".post-author-role").count() == 0
def test_guest_action_controls_disabled(page, seeded_db):
_seed_post()
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
card = page.locator(".post-card").first
card.wait_for(state="visible")
assert card.locator(".vote-up").first.is_disabled()
fab = page.locator(".feed-fab").first
assert "/auth/login" in (fab.get_attribute("href") or "")
def test_guest_docs_hide_admin(page, app_server):
page.goto(f"{BASE_URL}/docs/index.html", wait_until="domcontentloaded")
assert page.locator("a[href='/docs/admin.html']").count() == 0
assert page.locator("a[href='/docs/services.html']").count() == 0
assert requests.get(f"{BASE_URL}/docs/admin.html").status_code == 404
assert requests.get(f"{BASE_URL}/docs/services.html").status_code == 404
# ---------- Member (bob_test is the 2nd seeded user -> Member) ----------
def test_member_has_no_admin_nav(bob):
page, _ = bob
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
assert page.locator("a[href='/admin']").count() == 0
def test_member_sees_no_role_badges(bob):
page, _ = bob
_seed_post()
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".post-card").first.wait_for(state="visible")
assert page.locator(".post-author-role").count() == 0
def test_member_can_act(bob):
page, _ = bob
_seed_post()
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
fab = page.locator(".feed-fab").first
fab.wait_for(state="visible")
assert (fab.get_attribute("data-modal") or "") == "create-post-modal"
card = page.locator(".post-card").first
card.wait_for(state="visible")
assert not card.locator(".vote-up").first.is_disabled()
def test_member_docs_hide_admin(bob):
page, _ = bob
assert requests.get(f"{BASE_URL}/docs/admin.html", headers={"X-API-KEY": _key("bob_test")}).status_code == 404
page.goto(f"{BASE_URL}/docs/index.html", wait_until="domcontentloaded")
assert page.locator("a[href='/docs/admin.html']").count() == 0
# ---------- Admin (alice_test is the first seeded user -> Admin) ----------
def test_admin_has_admin_nav(alice):
page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
assert page.locator("a[href='/admin']").count() >= 1
def test_admin_sees_role_badges(alice):
page, _ = alice
_seed_post()
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".post-card").first.wait_for(state="visible")
assert page.locator(".post-author-role").count() >= 1
def test_admin_docs_access(alice):
page, _ = alice
assert requests.get(f"{BASE_URL}/docs/admin.html", headers={"X-API-KEY": _key("alice_test")}).status_code == 200
page.goto(f"{BASE_URL}/docs/index.html", wait_until="domcontentloaded")
assert page.locator("a[href='/docs/admin.html']").count() >= 1
+107 -2
View File
@@ -45,9 +45,114 @@ def test_services_sidebar_link(page, seeded_db):
assert "active" in (link.get_attribute("class") or "")
def test_services_no_services_message(page, seeded_db):
def test_services_index_lists_with_links(page, seeded_db):
user = seeded_db["alice"]
_promote_to_admin(user["username"])
login_user(page, user)
page.goto(f"{BASE_URL}/admin/services", wait_until="domcontentloaded")
assert page.is_visible("text=No services registered")
row = page.locator(".service-row[data-service='news']")
assert row.count() == 1
assert row.locator("a[href='/admin/services/news']").count() >= 1
assert page.locator(".service-row-desc").count() >= 1
# index is a slim list: no config forms or per-service controls
assert page.locator("[data-config-form]").count() == 0
def test_service_detail_has_tabs_and_config(page, seeded_db):
user = seeded_db["alice"]
_promote_to_admin(user["username"])
login_user(page, user)
page.goto(f"{BASE_URL}/admin/services/news", wait_until="domcontentloaded")
detail = page.locator("[data-service-detail][data-service='news']")
assert detail.count() == 1
assert detail.locator("[data-tabs] [data-tab='config']").count() == 1
assert detail.locator("[data-config-form]").count() == 1
assert detail.locator("input[name='news_grade_threshold']").count() == 1
for action in ("start", "stop", "run", "clear-logs"):
assert detail.locator(f"[data-action='{action}']").count() == 1
assert detail.locator("fieldset.config-section").count() >= 1
def test_service_detail_unknown_404(page, seeded_db):
user = seeded_db["alice"]
_promote_to_admin(user["username"])
login_user(page, user)
resp = page.request.get(f"{BASE_URL}/admin/services/nope")
assert resp.status == 404
assert page.request.get(f"{BASE_URL}/admin/services/nope/data").status == 404
def test_services_start_stop_toggles_enabled(page, seeded_db):
user = seeded_db["alice"]
_promote_to_admin(user["username"])
login_user(page, user)
page.goto(f"{BASE_URL}/admin/services", wait_until="domcontentloaded")
try:
assert page.request.post(f"{BASE_URL}/admin/services/news/stop").ok
data = page.request.get(f"{BASE_URL}/admin/services/data").json()
news = next(s for s in data["services"] if s["name"] == "news")
assert news["enabled"] is False
assert news["status"] == "stopped"
assert page.request.post(f"{BASE_URL}/admin/services/news/start").ok
data = page.request.get(f"{BASE_URL}/admin/services/data").json()
news = next(s for s in data["services"] if s["name"] == "news")
assert news["enabled"] is True
finally:
page.request.post(f"{BASE_URL}/admin/services/news/start")
def test_services_config_validation(page, seeded_db):
user = seeded_db["alice"]
_promote_to_admin(user["username"])
login_user(page, user)
page.goto(f"{BASE_URL}/admin/services", wait_until="domcontentloaded")
bad = page.request.post(f"{BASE_URL}/admin/services/news/config", form={"news_grade_threshold": "99"})
assert bad.status == 400
body = bad.json()
assert body["ok"] is False
assert "news_grade_threshold" in body["errors"]
try:
good = page.request.post(f"{BASE_URL}/admin/services/news/config", form={"news_grade_threshold": "8"})
assert good.ok
assert good.json()["ok"] is True
finally:
page.request.post(f"{BASE_URL}/admin/services/news/config", form={"news_grade_threshold": "7"})
def test_services_unknown_returns_404(page, seeded_db):
user = seeded_db["alice"]
_promote_to_admin(user["username"])
login_user(page, user)
page.goto(f"{BASE_URL}/admin/services", wait_until="domcontentloaded")
assert page.request.post(f"{BASE_URL}/admin/services/nope/stop").status == 404
def test_bots_service_registered_and_opt_in(page, seeded_db):
user = seeded_db["alice"]
_promote_to_admin(user["username"])
login_user(page, user)
page.goto(f"{BASE_URL}/admin/services", wait_until="domcontentloaded")
data = page.request.get(f"{BASE_URL}/admin/services/data").json()
bots = next(s for s in data["services"] if s["name"] == "bots")
assert bots["enabled"] is False
assert bots["status"] == "stopped"
assert "metrics" in bots
page.goto(f"{BASE_URL}/admin/services/bots", wait_until="domcontentloaded")
detail = page.locator("[data-service-detail][data-service='bots']")
assert detail.locator("input[name='bot_fleet_size']").count() == 1
assert detail.locator("input[name='bot_input_cost_per_1m']").count() == 1
def test_bots_config_float_validation(page, seeded_db):
user = seeded_db["alice"]
_promote_to_admin(user["username"])
login_user(page, user)
page.goto(f"{BASE_URL}/admin/services", wait_until="domcontentloaded")
bad = page.request.post(f"{BASE_URL}/admin/services/bots/config", form={"bot_input_cost_per_1m": "abc"})
assert bad.status == 400
assert "bot_input_cost_per_1m" in bad.json()["errors"]
try:
good = page.request.post(f"{BASE_URL}/admin/services/bots/config", form={"bot_input_cost_per_1m": "0.42"})
assert good.ok and good.json()["ok"] is True
finally:
page.request.post(f"{BASE_URL}/admin/services/bots/config", form={"bot_input_cost_per_1m": "0.27"})
+20 -3
View File
@@ -4,7 +4,7 @@ from datetime import datetime, timedelta, timezone
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, get_activity_calendar, get_streaks, get_activity_heatmap
from devplacepy.database import get_table, get_activity_calendar, get_streaks, get_activity_heatmap, get_activity_months
from devplacepy.utils import generate_uid, check_milestone_badges
_counter = [0]
@@ -81,8 +81,25 @@ def test_calendar_sums_across_sources(app_server):
def test_heatmap_shape(app_server):
uid = _make_user()
weeks = get_activity_heatmap(uid)
assert 52 <= len(weeks) <= 54
assert all(len(week) <= 7 for week in weeks)
assert len(weeks) == 53
assert all(len(week) == 7 for week in weeks)
def test_heatmap_anchors_at_first_contribution(app_server):
uid = _make_user()
first = datetime.now(timezone.utc) - timedelta(days=30)
_insert_post(uid, first)
weeks = get_activity_heatmap(uid)
expected = (first.date() - timedelta(days=first.date().weekday())).isoformat()
assert weeks[0][0]["date"] == expected
assert len(weeks) == 53
def test_activity_months_returns_six_labels(app_server):
uid = _make_user()
months = get_activity_months(get_activity_heatmap(uid))
assert len(months) == 6
assert all(len(label) == 3 for label in months)
def test_seven_day_streak_awards_on_fire_badge(app_server):