feat: add api key auth, devii agent, openai gateway, and admin service management
This commit introduces a comprehensive set of new features including API key authentication with CLI management commands (get, reset, backfill), a Devii agentic assistant with WebSocket terminal and session bootstrap, an OpenAI-compatible LLM gateway service, and an admin service management panel. It also adds Playwright browser automation for bot support, configures internal gateway URLs, refactors content editing/deletion to support JSON API responses, and updates documentation across AGENTS.md, README.md, and the developer docs site.
This commit is contained in:
+3
-3
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user