fix: correct typo in user authentication error message for invalid credentials

This commit is contained in:
2026-06-09 16:48:08 +00:00
parent 0b7bfda1ab
commit d5609880e2
175 changed files with 12660 additions and 4175 deletions
+69 -21
View File
@@ -42,6 +42,7 @@ def run_async(coro):
thread.start()
thread.join()
from devplacepy.database import refresh_snapshot
refresh_snapshot()
if "error" in box:
raise box["error"]
@@ -51,6 +52,7 @@ def run_async(coro):
@pytest.fixture(autouse=True)
def _fresh_db_snapshot():
from devplacepy.database import refresh_snapshot
refresh_snapshot()
yield
@@ -76,7 +78,9 @@ def pytest_runtest_makereport(item, call):
obj = item.funcargs[name]
if name in ("alice", "bob"):
obj = obj[0]
save_failure_screenshot(obj, item.nodeid.replace("::", "_").replace("/", "_"))
save_failure_screenshot(
obj, item.nodeid.replace("::", "_").replace("/", "_")
)
except Exception:
pass
@@ -96,8 +100,18 @@ def app_server(test_db_path):
env["PYTHONUNBUFFERED"] = "1"
log_file = tempfile.NamedTemporaryFile(suffix=f"_server_{PORT}.log", delete=False)
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "devplacepy.main:app",
"--host", "127.0.0.1", "--port", str(PORT), "--log-level", "warning"],
[
sys.executable,
"-m",
"uvicorn",
"devplacepy.main:app",
"--host",
"127.0.0.1",
"--port",
str(PORT),
"--log-level",
"warning",
],
cwd=str(PROJECT_ROOT),
env=env,
stdout=log_file,
@@ -115,6 +129,7 @@ def app_server(test_db_path):
while time.time() < deadline:
try:
import urllib.request
urllib.request.urlopen(f"{BASE_URL}/", timeout=2)
break
except Exception:
@@ -122,21 +137,21 @@ def app_server(test_db_path):
if poll is not None:
proc.wait()
raise RuntimeError(
f"Server process died (exit code {poll}). "
f"Log:\n{server_log()}"
f"Server process died (exit code {poll}). Log:\n{server_log()}"
)
time.sleep(0.5)
else:
proc.terminate()
proc.wait(timeout=5)
raise RuntimeError(
f"Server did not start after 30s. "
f"Log:\n{server_log()}"
)
raise RuntimeError(f"Server did not start after 30s. Log:\n{server_log()}")
from devplacepy.database import get_table as _get_table
ops_settings = _get_table("site_settings")
for key, value in (("rate_limit_per_minute", "1000000"), ("rate_limit_window_seconds", "60")):
for key, value in (
("rate_limit_per_minute", "1000000"),
("rate_limit_window_seconds", "60"),
):
if not ops_settings.find_one(key=key):
ops_settings.insert({"uid": f"test_{key}", "key": key, "value": value})
@@ -158,21 +173,29 @@ def app_server(test_db_path):
@pytest.fixture(scope="session")
def playwright_instance():
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
yield p
@pytest.fixture(scope="session")
def browser(playwright_instance):
headless = os.environ.get("PLAYWRIGHT_HEADLESS", "1") == "1"
b = playwright_instance.chromium.launch(
headless=headless, slow_mo=int(os.environ.get("PLAYWRIGHT_SLOW_MO", "0")), args=["--window-size=1400,900"],
headless=headless,
slow_mo=int(os.environ.get("PLAYWRIGHT_SLOW_MO", "0")),
args=["--window-size=1400,900"],
)
yield b
b.close()
@pytest.fixture(scope="module")
def browser_context(browser):
ctx = browser.new_context(viewport={"width": 1400, "height": 900}, permissions=["clipboard-read", "clipboard-write"])
ctx = browser.new_context(
viewport={"width": 1400, "height": 900},
permissions=["clipboard-read", "clipboard-write"],
)
yield ctx
ctx.close()
@@ -186,6 +209,7 @@ def page(browser_context):
yield p
p.close()
def signup_user(page, user):
page.bring_to_front()
page.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
@@ -208,6 +232,7 @@ def login_user(page, user):
def assert_share_copies(page, expected_fragment):
from playwright.sync_api import expect
share = page.locator("button[data-share]").first
share.scroll_into_view_if_needed()
share.click()
@@ -218,7 +243,9 @@ def assert_share_copies(page, expected_fragment):
except Exception:
clip = None
if clip:
assert clip.startswith("http") and expected_fragment in clip, f"clipboard={clip!r}"
assert clip.startswith("http") and expected_fragment in clip, (
f"clipboard={clip!r}"
)
@pytest.fixture(scope="session")
@@ -226,15 +253,20 @@ def seeded_db(app_server):
"""Create test users once at session level using requests directly."""
import urllib.request
import urllib.parse
users = [
("alice_test", "alice@test.devplace", "secret123"),
("bob_test", "bob@test.devplace", "secret456"),
]
for username, email, pw in users:
data = urllib.parse.urlencode({
"username": username, "email": email,
"password": pw, "confirm_password": pw,
}).encode()
data = urllib.parse.urlencode(
{
"username": username,
"email": email,
"password": pw,
"confirm_password": pw,
}
).encode()
req = urllib.request.Request(
f"{BASE_URL}/auth/signup",
data=data,
@@ -242,8 +274,16 @@ def seeded_db(app_server):
)
urllib.request.urlopen(req)
return {
"alice": {"username": "alice_test", "email": "alice@test.devplace", "password": "secret123"},
"bob": {"username": "bob_test", "email": "bob@test.devplace", "password": "secret456"},
"alice": {
"username": "alice_test",
"email": "alice@test.devplace",
"password": "secret123",
},
"bob": {
"username": "bob_test",
"email": "bob@test.devplace",
"password": "secret456",
},
}
@@ -255,7 +295,10 @@ def alice(page, seeded_db):
@pytest.fixture
def bob(browser, seeded_db):
ctx = browser.new_context(viewport={"width": 1400, "height": 900}, permissions=["clipboard-read", "clipboard-write"])
ctx = browser.new_context(
viewport={"width": 1400, "height": 900},
permissions=["clipboard-read", "clipboard-write"],
)
p = ctx.new_page()
p.set_default_timeout(15000)
p.bring_to_front()
@@ -267,7 +310,11 @@ def bob(browser, seeded_db):
@pytest.fixture
def mobile_page(browser, seeded_db):
ctx = browser.new_context(viewport={"width": 412, "height": 840}, has_touch=True, permissions=["clipboard-read", "clipboard-write"])
ctx = browser.new_context(
viewport={"width": 412, "height": 840},
has_touch=True,
permissions=["clipboard-read", "clipboard-write"],
)
p = ctx.new_page()
p.set_default_timeout(15000)
p.bring_to_front()
@@ -280,5 +327,6 @@ def mobile_page(browser, seeded_db):
@pytest.fixture(scope="session")
def local_db():
from devplacepy.database import init_db, db
init_db()
return db
+73 -34
View File
@@ -15,15 +15,20 @@ def seed_admin_news(count=3):
uid = str(uuid4())
title = f"Admin News Article {i}"
slug = make_combined_slug(title, uid)
news_table.insert({
"uid": uid, "slug": slug, "title": title,
"external_id": eid, "grade": 5 + i,
"status": "draft" if i == 0 else "published",
"show_on_landing": 1 if i == 1 else 0,
"source_name": "AdminTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": f"Test article {i} for admin tests.",
})
news_table.insert(
{
"uid": uid,
"slug": slug,
"title": title,
"external_id": eid,
"grade": 5 + i,
"status": "draft" if i == 0 else "published",
"show_on_landing": 1 if i == 1 else 0,
"source_name": "AdminTest",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": f"Test article {i} for admin tests.",
}
)
def seed_extra_users(count=30):
@@ -33,11 +38,17 @@ def seed_extra_users(count=30):
return
for i in range(count):
uid = str(uuid4())
users.insert({
"uid": uid, "username": f"pagu_{i:04d}", "email": f"pagu{i:04d}@test.devplace",
"password_hash": "x", "role": "Member", "is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
})
users.insert(
{
"uid": uid,
"username": f"pagu_{i:04d}",
"email": f"pagu{i:04d}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
def test_admin_redirect_unauth(page, app_server):
@@ -55,16 +66,24 @@ def test_admin_users_loads(alice):
def test_admin_users_pagination(alice):
page, _ = alice
import urllib.request, urllib.parse
for i in range(30):
data = urllib.parse.urlencode({
"username": f"pagu_{i:04d}", "email": f"pagu{i:04d}@test.devplace",
"password": "testpass123", "confirm_password": "testpass123",
}).encode()
data = urllib.parse.urlencode(
{
"username": f"pagu_{i:04d}",
"email": f"pagu{i:04d}@test.devplace",
"password": "testpass123",
"confirm_password": "testpass123",
}
).encode()
try:
urllib.request.urlopen(urllib.request.Request(
f"{BASE_URL}/auth/signup", data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
))
urllib.request.urlopen(
urllib.request.Request(
f"{BASE_URL}/auth/signup",
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
)
except Exception:
pass
page.goto(f"{BASE_URL}/admin/users", wait_until="domcontentloaded")
@@ -86,10 +105,14 @@ def test_admin_news_publish_toggle(alice):
seed_admin_news()
page.goto(f"{BASE_URL}/admin/news", wait_until="domcontentloaded")
toggle_sel = "form[action*='/publish'] button.admin-toggle-switch"
was_active = "active" in (page.locator(toggle_sel).first.get_attribute("class") or "")
was_active = "active" in (
page.locator(toggle_sel).first.get_attribute("class") or ""
)
page.locator(toggle_sel).first.click()
page.wait_for_url(f"{BASE_URL}/admin/news", wait_until="domcontentloaded")
is_active = "active" in (page.locator(toggle_sel).first.get_attribute("class") or "")
is_active = "active" in (
page.locator(toggle_sel).first.get_attribute("class") or ""
)
assert is_active != was_active
@@ -98,10 +121,14 @@ def test_admin_news_landing_toggle(alice):
seed_admin_news()
page.goto(f"{BASE_URL}/admin/news", wait_until="domcontentloaded")
toggle_sel = "form[action*='/landing'] button.admin-toggle-switch"
was_active = "active" in (page.locator(toggle_sel).first.get_attribute("class") or "")
was_active = "active" in (
page.locator(toggle_sel).first.get_attribute("class") or ""
)
page.locator(toggle_sel).first.click()
page.wait_for_url(f"{BASE_URL}/admin/news", wait_until="domcontentloaded")
is_active = "active" in (page.locator(toggle_sel).first.get_attribute("class") or "")
is_active = "active" in (
page.locator(toggle_sel).first.get_attribute("class") or ""
)
assert is_active != was_active
@@ -127,17 +154,25 @@ def test_admin_news_pagination(alice):
next_btn = page.locator("a.pagination-btn:has-text('Next')")
if next_btn.count() > 0:
next_btn.click()
page.wait_for_url(f"{BASE_URL}/admin/news?page=2", wait_until="domcontentloaded")
page.wait_for_url(
f"{BASE_URL}/admin/news?page=2", wait_until="domcontentloaded"
)
assert "Page 2" in page.text_content(".pagination-info")
def _seed_target_user():
uid = str(uuid4())
get_table("users").insert({
"uid": uid, "username": f"target_{uid[:8]}", "email": f"target_{uid[:8]}@test.devplace",
"password_hash": "x", "role": "Member", "is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
})
get_table("users").insert(
{
"uid": uid,
"username": f"target_{uid[:8]}",
"email": f"target_{uid[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
@@ -180,8 +215,12 @@ def test_admin_news_featured_toggle(alice):
seed_admin_news()
page.goto(f"{BASE_URL}/admin/news", wait_until="domcontentloaded")
toggle_sel = "form[action$='/toggle'] button.admin-toggle-switch"
was_active = "active" in (page.locator(toggle_sel).first.get_attribute("class") or "")
was_active = "active" in (
page.locator(toggle_sel).first.get_attribute("class") or ""
)
page.locator(toggle_sel).first.click()
page.wait_for_url(f"{BASE_URL}/admin/news", wait_until="domcontentloaded")
is_active = "active" in (page.locator(toggle_sel).first.get_attribute("class") or "")
is_active = "active" in (
page.locator(toggle_sel).first.get_attribute("class") or ""
)
assert is_active != was_active
+89 -27
View File
@@ -8,8 +8,13 @@ from devplacepy.utils import generate_uid
from devplacepy.services.openai_gateway import GatewayService
from devplacepy.services.openai_gateway.analytics import build_user_usage
from devplacepy.services.devii.config import (
build_settings, FIELD_AI_URL, FIELD_AI_MODEL, FIELD_AI_KEY,
FIELD_MAX_ITERATIONS, FIELD_PLAN_REQUIRED, FIELD_VERIFY_REQUIRED,
build_settings,
FIELD_AI_URL,
FIELD_AI_MODEL,
FIELD_AI_KEY,
FIELD_MAX_ITERATIONS,
FIELD_PLAN_REQUIRED,
FIELD_VERIFY_REQUIRED,
)
@@ -21,42 +26,69 @@ def _make_user(role="Member"):
username = f"aiu_{generate_uid()[:8]}"
api_key = generate_uid()
uid = generate_uid()
get_table("users").insert({
"uid": uid, "username": username, "email": f"{username}@t.dev",
"api_key": api_key, "role": role, "is_active": True,
})
get_table("users").insert(
{
"uid": uid,
"username": username,
"email": f"{username}@t.dev",
"api_key": api_key,
"role": role,
"is_active": True,
}
)
return uid, username, api_key
def _make_request(headers):
raw = [(k.lower().encode(), v.encode()) for k, v in headers.items()]
return Request({"type": "http", "method": "POST", "path": "/openai/v1/chat/completions",
"query_string": b"", "headers": raw, "state": {}})
return Request(
{
"type": "http",
"method": "POST",
"path": "/openai/v1/chat/completions",
"query_string": b"",
"headers": raw,
"state": {},
}
)
def _seed_gateway(uid, n=3, cost=0.01, kind="user"):
table = get_table("gateway_usage_ledger")
for _ in range(n):
table.insert({
"created_at": _now_iso(),
"owner_kind": kind, "owner_id": uid,
"backend": "chat", "model": "molodetz",
"success": 1, "status_code": 200,
"prompt_tokens": 1000, "completion_tokens": 300, "total_tokens": 1300,
"upstream_latency_ms": 1500.0, "tokens_per_second": 40.0,
"cost_usd": cost,
})
table.insert(
{
"created_at": _now_iso(),
"owner_kind": kind,
"owner_id": uid,
"backend": "chat",
"model": "molodetz",
"success": 1,
"status_code": 200,
"prompt_tokens": 1000,
"completion_tokens": 300,
"total_tokens": 1300,
"upstream_latency_ms": 1500.0,
"tokens_per_second": 40.0,
"cost_usd": cost,
}
)
def _devii_cfg():
return {
FIELD_AI_URL: "", FIELD_AI_MODEL: "", FIELD_AI_KEY: "INTERNAL-KEY",
FIELD_MAX_ITERATIONS: "40", FIELD_PLAN_REQUIRED: "1", FIELD_VERIFY_REQUIRED: "1",
FIELD_AI_URL: "",
FIELD_AI_MODEL: "",
FIELD_AI_KEY: "INTERNAL-KEY",
FIELD_MAX_ITERATIONS: "40",
FIELD_PLAN_REQUIRED: "1",
FIELD_VERIFY_REQUIRED: "1",
}
# ---------- Devii authenticates the LLM with the user's own key ----------
def test_devii_user_session_uses_own_api_key(local_db):
cfg = _devii_cfg()
user_settings = build_settings(cfg, "http://x", "USER-KEY-123", "user")
@@ -67,7 +99,9 @@ def test_devii_user_session_uses_own_api_key(local_db):
def test_gateway_allows_users_by_default(local_db):
field = next(f for f in GatewayService().config_fields if f.key == "gateway_allow_users")
field = next(
f for f in GatewayService().config_fields if f.key == "gateway_allow_users"
)
assert field.default is True
@@ -75,12 +109,18 @@ def test_gateway_attributes_calls_to_the_user(local_db):
member_uid, _, member_key = _make_user(role="Member")
admin_uid, _, admin_key = _make_user(role="Admin")
svc = GatewayService()
assert svc.resolve_owner(_make_request({"Authorization": f"Bearer {member_key}"})) == ("user", member_uid)
assert svc.resolve_owner(_make_request({"X-API-KEY": admin_key})) == ("admin", admin_uid)
assert svc.resolve_owner(
_make_request({"Authorization": f"Bearer {member_key}"})
) == ("user", member_uid)
assert svc.resolve_owner(_make_request({"X-API-KEY": admin_key})) == (
"admin",
admin_uid,
)
# ---------- Per-user usage analytics ----------
def test_build_user_usage_aggregates(local_db):
uid, _, _ = _make_user()
_seed_gateway(uid, n=4, cost=0.02)
@@ -114,17 +154,25 @@ def test_build_user_usage_empty_for_unknown(local_db):
# ---------- Quota percentage (what the user is allowed to see) ----------
def test_ai_quota_percentage_and_clamp(local_db):
from devplacepy.services.manager import service_manager
from devplacepy.services.devii import DeviiService
from devplacepy.routers.profile import _ai_quota
if service_manager.get_service("devii") is None:
service_manager.register(DeviiService())
set_setting("devii_user_daily_usd", "2.0")
try:
half, _, _ = _make_user()
get_table("devii_usage_ledger").insert(
{"owner_kind": "user", "owner_id": half, "created_at": _now_iso(), "cost_usd": 1.0})
{
"owner_kind": "user",
"owner_id": half,
"created_at": _now_iso(),
"cost_usd": 1.0,
}
)
quota = _ai_quota(half)
assert quota is not None
assert quota["used_pct"] == 50.0
@@ -133,7 +181,13 @@ def test_ai_quota_percentage_and_clamp(local_db):
over, _, _ = _make_user()
for _ in range(3):
get_table("devii_usage_ledger").insert(
{"owner_kind": "user", "owner_id": over, "created_at": _now_iso(), "cost_usd": 1.0})
{
"owner_kind": "user",
"owner_id": over,
"created_at": _now_iso(),
"cost_usd": 1.0,
}
)
capped = _ai_quota(over)
assert capped["used_pct"] == 100.0
finally:
@@ -142,6 +196,7 @@ def test_ai_quota_percentage_and_clamp(local_db):
# ---------- End-to-end: profile page visibility per role ----------
def _goto_profile(page, username):
page.goto(f"{BASE_URL}/profile/{username}", wait_until="domcontentloaded")
@@ -150,7 +205,10 @@ def test_admin_sees_full_ai_usage_on_profile(alice):
page, user = alice
_goto_profile(page, user["username"])
page.locator("[data-ai-usage]").wait_for(state="visible")
assert page.locator("[data-ai-usage] .ai-usage-title", has_text="AI usage").count() == 1
assert (
page.locator("[data-ai-usage] .ai-usage-title", has_text="AI usage").count()
== 1
)
assert page.locator("[data-ai-usage] [data-ai-quota]").count() == 1
@@ -159,7 +217,9 @@ def test_member_sees_only_quota_percentage(bob):
_goto_profile(page, user["username"])
card = page.locator(".ai-quota-only")
card.wait_for(state="visible")
assert page.locator(".ai-quota-only .ai-usage-title", has_text="AI quota").count() == 1
assert (
page.locator(".ai-quota-only .ai-usage-title", has_text="AI quota").count() == 1
)
assert "%" in page.locator(".ai-quota-only .ai-quota-pct").inner_text()
assert page.locator("[data-ai-usage]").count() == 0
@@ -173,7 +233,9 @@ def test_member_cannot_see_ai_on_another_profile(bob, seeded_db):
def test_admin_user_ai_usage_endpoint_returns_data(alice, seeded_db):
page, _ = alice
alice_uid = get_table("users").find_one(username=seeded_db["alice"]["username"])["uid"]
alice_uid = get_table("users").find_one(username=seeded_db["alice"]["username"])[
"uid"
]
resp = page.request.get(f"{BASE_URL}/admin/users/{alice_uid}/ai-usage?hours=24")
assert resp.status == 200
data = resp.json()
+70 -28
View File
@@ -14,10 +14,16 @@ def _signup(password="secret123"):
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)
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
@@ -38,47 +44,74 @@ def test_signup_assigns_api_key(app_server):
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)
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
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)
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
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)
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
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)
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
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)
r = requests.post(
f"{BASE_URL}/follow/{target}",
headers={"X-API-KEY": "not-a-real-key"},
allow_redirects=False,
)
assert r.status_code == 401
@@ -86,8 +119,11 @@ 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)
r = requests.post(
f"{BASE_URL}/follow/{target}",
headers={"Authorization": f"Basic {token}"},
allow_redirects=False,
)
assert r.status_code == 401
@@ -106,11 +142,17 @@ def test_regenerate_invalidates_old_key(app_server):
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)
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)
new = requests.post(
f"{BASE_URL}/follow/{target}",
headers={"X-API-KEY": new_key},
allow_redirects=False,
)
assert new.status_code in (302, 200)
+41 -17
View File
@@ -21,13 +21,17 @@ def test_forgot_password_submit_shows_sent(page, app_server):
def test_reset_password_page_loads(page, app_server):
page.goto(f"{BASE_URL}/auth/reset-password/sometoken", wait_until="domcontentloaded")
page.goto(
f"{BASE_URL}/auth/reset-password/sometoken", wait_until="domcontentloaded"
)
assert page.is_visible("input#password")
assert page.is_visible("input#confirm_password")
def test_reset_password_mismatch_shows_error(page, app_server):
page.goto(f"{BASE_URL}/auth/reset-password/sometoken", wait_until="domcontentloaded")
page.goto(
f"{BASE_URL}/auth/reset-password/sometoken", wait_until="domcontentloaded"
)
page.fill("#password", "abcdef1")
page.fill("#confirm_password", "different1")
page.click("button:has-text('Reset Password')")
@@ -38,20 +42,35 @@ def test_reset_password_full_flow(page, app_server):
uname = f"reset_{int(time.time() * 1000)}"
email = f"{uname}@t.dev"
uid = generate_uid()
get_table("users").insert({
"uid": uid, "username": uname, "email": email,
"password_hash": hash_password("oldpass123"),
"bio": "", "location": "", "git_link": "", "website": "",
"role": "Member", "is_active": True, "level": 1, "xp": 0, "stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
})
get_table("users").insert(
{
"uid": uid,
"username": uname,
"email": email,
"password_hash": hash_password("oldpass123"),
"bio": "",
"location": "",
"git_link": "",
"website": "",
"role": "Member",
"is_active": True,
"level": 1,
"xp": 0,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
token = f"tok{uid[:8]}"
get_table("password_resets").insert({
"uid": generate_uid(), "user_uid": uid,
"token": hashlib.sha256(token.encode()).hexdigest(),
"expires_at": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(),
"used": False, "created_at": datetime.now(timezone.utc).isoformat(),
})
get_table("password_resets").insert(
{
"uid": generate_uid(),
"user_uid": uid,
"token": hashlib.sha256(token.encode()).hexdigest(),
"expires_at": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(),
"used": False,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
page.goto(f"{BASE_URL}/auth/reset-password/{token}", wait_until="domcontentloaded")
page.fill("#password", "newpass456")
page.fill("#confirm_password", "newpass456")
@@ -149,7 +168,9 @@ def test_signup_invalid_username(page, app_server):
page.fill("#password", "secret123")
page.fill("#confirm_password", "secret123")
page.click("button:has-text('Create account')")
expect(page.locator("text=Username must be between 3 and 32 characters")).to_be_visible()
expect(
page.locator("text=Username must be between 3 and 32 characters")
).to_be_visible()
def test_login_page_loads(page, app_server):
@@ -271,7 +292,10 @@ def test_login_next_redirects_to_target(page, app_server, seeded_db):
def test_login_next_rejects_external(page, app_server, seeded_db):
page.goto(f"{BASE_URL}/auth/login?next=https://evil.example.com", wait_until="domcontentloaded")
page.goto(
f"{BASE_URL}/auth/login?next=https://evil.example.com",
wait_until="domcontentloaded",
)
page.fill("#email", seeded_db["alice"]["email"])
page.fill("#password", seeded_db["alice"]["password"])
page.click("button:has-text('Sign in')")
+46 -14
View File
@@ -14,7 +14,10 @@ LOGIN_REDIRECT = "/auth/login"
FEED_REDIRECT = "/feed"
JSON_BODY_OVERRIDES = {
"push-register": {"endpoint": "https://example.test/p", "keys": {"p256dh": "a", "auth": "b"}},
"push-register": {
"endpoint": "https://example.test/p",
"keys": {"p256dh": "a", "auth": "b"},
},
"gateway-chat": {"model": "x", "messages": [{"role": "user", "content": "hi"}]},
}
@@ -22,10 +25,16 @@ JSON_BODY_OVERRIDES = {
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)
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"]
@@ -35,7 +44,9 @@ 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"])
endpoints.extend(
build_services_group(service_manager.describe_all(), BASE_URL)["endpoints"]
)
return endpoints
@@ -62,7 +73,11 @@ def _build(endpoint):
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"},
{
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")}
@@ -84,7 +99,12 @@ def _call(endpoint, headers):
def _redirects_to(response, target):
return response.status_code in (302, 303, 307, 308) and target in response.headers.get("location", "")
return response.status_code in (
302,
303,
307,
308,
) and target in response.headers.get("location", "")
def _is_auth_rejected(response):
@@ -111,28 +131,40 @@ def test_documented_minimal_role_matches_enforcement(seeded_db):
failures = []
for endpoint in endpoints:
auth = endpoint["auth"]
label = f"{endpoint['method']} {endpoint['path']} ({endpoint['id']}, doc={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})")
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})")
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})")
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})")
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)
assert not failures, "Auth enforcement does not match documentation:\n" + "\n".join(
failures
)
def test_every_endpoint_documents_minimal_role(seeded_db):
+40 -17
View File
@@ -15,10 +15,16 @@ def _session():
_counter[0] += 1
name = f"bkm{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": "secret123", "confirm_password": "secret123",
}, allow_redirects=True)
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
@@ -28,23 +34,38 @@ def _uid(username):
def _make_post(owner_uid, title):
uid = generate_uid()
get_table("posts").insert({
"uid": uid, "user_uid": owner_uid, "slug": f"{uid[:8]}-bookmark-post",
"title": title, "content": "bookmark target content", "topic": "random",
"project_uid": None, "image": None, "stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
})
get_table("posts").insert(
{
"uid": uid,
"user_uid": owner_uid,
"slug": f"{uid[:8]}-bookmark-post",
"title": title,
"content": "bookmark target content",
"topic": "random",
"project_uid": None,
"image": None,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def _make_gist(owner_uid, title):
uid = generate_uid()
get_table("gists").insert({
"uid": uid, "user_uid": owner_uid, "slug": f"{uid[:8]}-bookmark-gist",
"title": title, "description": None, "source_code": "print('x')",
"language": "python", "stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
})
get_table("gists").insert(
{
"uid": uid,
"user_uid": owner_uid,
"slug": f"{uid[:8]}-bookmark-gist",
"title": title,
"description": None,
"source_code": "print('x')",
"language": "python",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
@@ -90,6 +111,8 @@ def test_bookmark_requires_login(app_server):
owner_s, owner_name = _session()
post_uid = _make_post(_uid(owner_name), "Needs login")
anon = requests.Session()
r = anon.post(f"{BASE_URL}/bookmarks/post/{post_uid}", headers=AJAX, allow_redirects=False)
r = anon.post(
f"{BASE_URL}/bookmarks/post/{post_uid}", headers=AJAX, allow_redirects=False
)
assert r.status_code == 303
assert get_table("bookmarks").count(target_uid=post_uid) == 0
+3 -1
View File
@@ -108,7 +108,9 @@ 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.locator(".comment-body:has-text('Delete me') .comment-action-btn:has-text('Delete')").click()
page.locator(
".comment-body:has-text('Delete me') .comment-action-btn:has-text('Delete')"
).click()
page.locator(".dialog-overlay.visible .dialog-confirm").click()
page.wait_for_timeout(500)
assert not page.is_visible("text=Delete me")
+49 -24
View File
@@ -10,13 +10,15 @@ from devplacepy.utils import generate_uid
def _make_user(role="Member"):
username = f"cli_{generate_uid()[:8]}"
get_table("users").insert({
"uid": generate_uid(),
"username": username,
"email": f"{username}@t.dev",
"role": role,
"created_at": datetime.now(timezone.utc).isoformat(),
})
get_table("users").insert(
{
"uid": generate_uid(),
"username": username,
"email": f"{username}@t.dev",
"role": role,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return username
@@ -60,11 +62,13 @@ def test_news_clear_deletes_all_tables(local_db, capsys):
def test_news_sanitize_strips_html(local_db, capsys):
uid = generate_uid()
get_table("news").insert({
"uid": uid,
"description": "<b>Bold</b> &amp; clean",
"content": "<p>Body</p>",
})
get_table("news").insert(
{
"uid": uid,
"description": "<b>Bold</b> &amp; clean",
"content": "<p>Body</p>",
}
)
cli.cmd_news_sanitize(argparse.Namespace())
row = get_table("news").find_one(uid=uid)
assert row["description"] == "Bold & clean"
@@ -76,14 +80,22 @@ def test_attachments_prune_removes_only_stale_orphans(local_db, capsys):
old_uid = generate_uid()
fresh_uid = generate_uid()
attachments = get_table("attachments")
attachments.insert({
"uid": old_uid, "target_type": "", "target_uid": "",
"created_at": "2000-01-01T00:00:00+00:00",
})
attachments.insert({
"uid": fresh_uid, "target_type": "", "target_uid": "",
"created_at": datetime.now(timezone.utc).isoformat(),
})
attachments.insert(
{
"uid": old_uid,
"target_type": "",
"target_uid": "",
"created_at": "2000-01-01T00:00:00+00:00",
}
)
attachments.insert(
{
"uid": fresh_uid,
"target_type": "",
"target_uid": "",
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
cli.cmd_attachments_prune(argparse.Namespace(hours=24))
assert attachments.find_one(uid=old_uid) is None
assert attachments.find_one(uid=fresh_uid) is not None
@@ -92,7 +104,10 @@ def test_attachments_prune_removes_only_stale_orphans(local_db, capsys):
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"])
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
@@ -100,7 +115,10 @@ def test_apikey_get_prints_key(local_db, capsys):
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"])
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()
@@ -112,8 +130,15 @@ def test_apikey_reset_changes_key(local_db, capsys):
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()})
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")
+79 -22
View File
@@ -15,7 +15,12 @@ from devplacepy.services.containers.service import ContainerService
from devplacepy.services.containers.api import INSTANCE_LABEL
from devplacepy.services.devii.tasks.schedule import Schedule, now_utc, to_iso
_CONTAINER_TABLES = ("instances", "instance_events", "instance_metrics", "instance_schedules")
_CONTAINER_TABLES = (
"instances",
"instance_events",
"instance_metrics",
"instance_schedules",
)
@pytest.fixture(autouse=True)
@@ -47,10 +52,20 @@ def env(tmp_path, monkeypatch):
# ---------------- backend argv ----------------
def test_build_run_argv_exact():
spec = RunSpec(image="ppy:latest", name="inst", labels={INSTANCE_LABEL: "u1"}, env={"A": "B"},
cpu_limit="1.5", mem_limit="512m", ports=[PortMapping(8080, 80)],
mounts=[Mount("/ws", "/app")], restart_policy="on-failure", command=["python", "app.py"])
spec = RunSpec(
image="ppy:latest",
name="inst",
labels={INSTANCE_LABEL: "u1"},
env={"A": "B"},
cpu_limit="1.5",
mem_limit="512m",
ports=[PortMapping(8080, 80)],
mounts=[Mount("/ws", "/app")],
restart_policy="on-failure",
command=["python", "app.py"],
)
argv = build_run_argv(spec)
assert argv[:5] == ["docker", "run", "-d", "--name", "inst"]
assert "--label" in argv and f"{INSTANCE_LABEL}=u1" in argv
@@ -67,14 +82,19 @@ def test_never_policy_not_passed_to_docker():
def test_parse_size():
assert parse_size("1.0GiB") == 1024 ** 3
assert parse_size("512MB") == 512 * 1024 ** 2
assert parse_size("1.0GiB") == 1024**3
assert parse_size("512MB") == 512 * 1024**2
# ---------------- instance creation ----------------
def test_create_instance_uses_shared_image(env):
inst = run_async(api.create_instance(env["project"], name="inst", actor=("user", env["user"]["uid"])))
inst = run_async(
api.create_instance(
env["project"], name="inst", actor=("user", env["user"]["uid"])
)
)
assert inst["name"] == "inst"
assert inst["owner_uid"] == "ctest-u1"
spec = api.run_spec_for(inst, config.CONTAINER_IMAGE)
@@ -85,6 +105,7 @@ def test_create_instance_uses_shared_image(env):
def test_create_instance_requires_built_image(env):
async def no_image(ref):
return False
env["fake"].image_exists = no_image
with pytest.raises(api.ContainerError):
run_async(api.create_instance(env["project"], name="inst"))
@@ -92,8 +113,11 @@ def test_create_instance_requires_built_image(env):
# ---------------- reconcile ----------------
def _ready_instance(env, **kwargs):
return run_async(api.create_instance(env["project"], name=kwargs.pop("name", "inst"), **kwargs))
return run_async(
api.create_instance(env["project"], name=kwargs.pop("name", "inst"), **kwargs)
)
def test_reconcile_launches_and_stops(env):
@@ -113,7 +137,13 @@ def test_reconcile_launches_and_stops(env):
def test_reconcile_reaps_orphan(env):
fake = env["fake"]
run_async(fake.run(RunSpec(image="ppy:latest", name="ghost", labels={INSTANCE_LABEL: "missing-uid"})))
run_async(
fake.run(
RunSpec(
image="ppy:latest", name="ghost", labels={INSTANCE_LABEL: "missing-uid"}
)
)
)
service = ContainerService()
run_async(service.run_once())
assert not run_async(fake.ps())
@@ -146,6 +176,7 @@ def test_schedule_fires(env):
# ---------------- HTTP admin gate ----------------
def _promote_admin(username: str) -> None:
users = get_table("users")
user = users.find_one(username=username)
@@ -159,25 +190,45 @@ def _api_key(username: str) -> str:
def test_http_non_admin_forbidden(app_server, page, seeded_db):
project = requests.post(f"{BASE_URL}/projects/create",
headers={"X-API-KEY": _api_key("bob_test"), "Accept": "application/json"},
data={"title": "NoAdmin", "description": "x", "project_type": "software", "status": "s"})
project = requests.post(
f"{BASE_URL}/projects/create",
headers={"X-API-KEY": _api_key("bob_test"), "Accept": "application/json"},
data={
"title": "NoAdmin",
"description": "x",
"project_type": "software",
"status": "s",
},
)
slug = project.json()["data"]["slug"] or project.json()["data"]["uid"]
r = requests.get(f"{BASE_URL}/projects/{slug}/containers/data",
headers={"X-API-KEY": _api_key("bob_test"), "Accept": "application/json"})
r = requests.get(
f"{BASE_URL}/projects/{slug}/containers/data",
headers={"X-API-KEY": _api_key("bob_test"), "Accept": "application/json"},
)
assert r.status_code == 403
def test_ingress_validation(env):
from devplacepy.services.containers.backend.base import PortMapping
assert api.validate_ingress("zwoeks", 8899, [PortMapping(8899, 8899)]) == ("zwoeks", 8899)
assert api.validate_ingress("zwoeks", 8899, [PortMapping(8899, 8899)]) == (
"zwoeks",
8899,
)
assert api.validate_ingress("", None, []) == ("", 0)
with pytest.raises(api.ContainerError):
api.validate_ingress("BAD SLUG", None, [PortMapping(80, 80)])
with pytest.raises(api.ContainerError):
api.validate_ingress("x", 9999, [PortMapping(80, 80)])
store.create_instance({"uid": "z", "project_uid": "ctest-p1", "name": "z",
"ports_json": "[]", "ingress_slug": "taken"})
store.create_instance(
{
"uid": "z",
"project_uid": "ctest-p1",
"name": "z",
"ports_json": "[]",
"ingress_slug": "taken",
}
)
with pytest.raises(api.ContainerError):
api.validate_ingress("taken", None, [PortMapping(80, 80)])
@@ -208,11 +259,17 @@ def test_http_ingress_proxy(app_server):
thread.start()
slug = f"ing{port}"
uid = f"ingtest-{port}"
get_table("instances").insert({
"uid": uid, "name": "ingress", "project_uid": "ingtest", "status": "running",
"ingress_slug": slug, "ingress_port": 8000,
"ports_json": f'[{{"host": {port}, "container": 8000, "proto": "tcp"}}]',
})
get_table("instances").insert(
{
"uid": uid,
"name": "ingress",
"project_uid": "ingtest",
"status": "running",
"ingress_slug": slug,
"ingress_port": 8000,
"ports_json": f'[{{"host": {port}, "container": 8000, "proto": "tcp"}}]',
}
)
try:
r = requests.get(f"{BASE_URL}/p/{slug}/foo")
assert r.status_code == 200, r.text
+51 -15
View File
@@ -13,10 +13,16 @@ 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)
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
@@ -47,17 +53,28 @@ def test_authed_pages_serve_json_and_html(app_server):
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"})
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
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)
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
@@ -68,8 +85,11 @@ def test_action_returns_envelope_for_json(app_server):
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)
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/")
@@ -100,18 +120,34 @@ def test_unauthenticated_json_request_is_401_not_redirect(app_server):
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
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
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"]})
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]
+24 -7
View File
@@ -1,6 +1,11 @@
from datetime import datetime, timezone
from devplacepy.content import is_owner, canonical_redirect, first_image_url, enrich_items
from devplacepy.content import (
is_owner,
canonical_redirect,
first_image_url,
enrich_items,
)
from devplacepy.database import get_table, get_users_by_uids
from devplacepy.utils import generate_uid
@@ -38,15 +43,27 @@ def test_first_image_url_none_when_absent():
def test_enrich_items_attaches_author_and_extras(local_db):
uid = generate_uid()
username = f"enrich_{uid[:8]}"
get_table("users").insert({
"uid": uid, "username": username, "email": f"{username}@t.dev",
"created_at": datetime.now(timezone.utc).isoformat(),
})
get_table("users").insert(
{
"uid": uid,
"username": username,
"email": f"{username}@t.dev",
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
item_uid = generate_uid()
items = [{"uid": item_uid, "user_uid": uid, "created_at": datetime.now(timezone.utc).isoformat()}]
items = [
{
"uid": item_uid,
"user_uid": uid,
"created_at": datetime.now(timezone.utc).isoformat(),
}
]
authors = get_users_by_uids([uid])
enriched = enrich_items(
items, "post", authors,
items,
"post",
authors,
extra_maps={"comment_count": {item_uid: 4}, "flag": lambda item: "ok"},
)
entry = enriched[0]
+73 -32
View File
@@ -20,39 +20,63 @@ def _now():
def _user():
uid = generate_uid()
get_table("users").insert({
"uid": uid, "username": f"dbh_{uid[:8]}", "email": f"{uid[:8]}@t.dev",
"created_at": _now(),
})
get_table("users").insert(
{
"uid": uid,
"username": f"dbh_{uid[:8]}",
"email": f"{uid[:8]}@t.dev",
"created_at": _now(),
}
)
return uid
def _post(owner, **extra):
uid = generate_uid()
get_table("posts").insert({
"uid": uid, "user_uid": owner, "slug": f"{uid[:8]}-post",
"title": None, "content": "db helper post body", "topic": "random",
"project_uid": None, "image": None, "stars": 0, "created_at": _now(),
**extra,
})
get_table("posts").insert(
{
"uid": uid,
"user_uid": owner,
"slug": f"{uid[:8]}-post",
"title": None,
"content": "db helper post body",
"topic": "random",
"project_uid": None,
"image": None,
"stars": 0,
"created_at": _now(),
**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(),
})
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(),
})
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):
@@ -68,11 +92,16 @@ def test_get_vote_counts_groups_up_and_down(local_db):
target = generate_uid()
votes = get_table("votes")
for value in (1, 1, -1):
votes.insert({
"uid": generate_uid(), "user_uid": generate_uid(),
"target_uid": target, "target_type": "post", "value": value,
"created_at": _now(),
})
votes.insert(
{
"uid": generate_uid(),
"user_uid": generate_uid(),
"target_uid": target,
"target_type": "post",
"value": value,
"created_at": _now(),
}
)
ups, downs = get_vote_counts([target])
assert ups[target] == 2
assert downs[target] == 1
@@ -81,10 +110,16 @@ def test_get_vote_counts_groups_up_and_down(local_db):
def test_get_user_votes_returns_user_value(local_db):
target = generate_uid()
user = generate_uid()
get_table("votes").insert({
"uid": generate_uid(), "user_uid": user,
"target_uid": target, "target_type": "post", "value": 1, "created_at": _now(),
})
get_table("votes").insert(
{
"uid": generate_uid(),
"user_uid": user,
"target_uid": target,
"target_type": "post",
"value": 1,
"created_at": _now(),
}
)
assert get_user_votes(user, [target]) == {target: 1}
@@ -92,10 +127,16 @@ def test_get_comment_counts_by_post_uids(local_db):
post = generate_uid()
comments = get_table("comments")
for _ in range(2):
comments.insert({
"uid": generate_uid(), "user_uid": generate_uid(),
"target_type": "post", "target_uid": post, "content": "hi", "created_at": _now(),
})
comments.insert(
{
"uid": generate_uid(),
"user_uid": generate_uid(),
"target_type": "post",
"target_uid": post,
"content": "hi",
"created_at": _now(),
}
)
assert get_comment_counts_by_post_uids([post]) == {post: 2}
+53 -12
View File
@@ -5,7 +5,10 @@ from tests.conftest import BASE_URL
from devplacepy.database import get_table, set_setting
from devplacepy.utils import generate_uid
from devplacepy.services.devii.config import (
effective_daily_limit, FIELD_USER_DAILY_USD, FIELD_GUEST_DAILY_USD, FIELD_ADMIN_DAILY_USD,
effective_daily_limit,
FIELD_USER_DAILY_USD,
FIELD_GUEST_DAILY_USD,
FIELD_ADMIN_DAILY_USD,
)
from devplacepy.services.devii.store import UsageLedger
@@ -20,15 +23,20 @@ def _now_iso():
def _seed_ledger(owner_kind, owner_id, cost, n=1):
table = get_table(LEDGER)
for _ in range(n):
table.insert({
"owner_kind": owner_kind, "owner_id": owner_id,
"created_at": _now_iso(), "cost_usd": cost,
})
table.insert(
{
"owner_kind": owner_kind,
"owner_id": owner_id,
"created_at": _now_iso(),
"cost_usd": cost,
}
)
def _ensure_devii():
from devplacepy.services.manager import service_manager
from devplacepy.services.devii import DeviiService
if service_manager.get_service("devii") is None:
service_manager.register(DeviiService())
return service_manager.get_service("devii")
@@ -37,17 +45,28 @@ def _ensure_devii():
def _make_user(role="Member"):
uid = generate_uid()
username = f"quota_{uid[-12:]}"
get_table("users").insert({
"uid": uid, "username": username, "email": f"{username}@t.dev",
"api_key": generate_uid(), "role": role, "is_active": True,
})
get_table("users").insert(
{
"uid": uid,
"username": username,
"email": f"{username}@t.dev",
"api_key": generate_uid(),
"role": role,
"is_active": True,
}
)
return uid, username
# ---------- The effective-limit resolver (single source of truth) ----------
def test_effective_limit_resolver():
cfg = {FIELD_USER_DAILY_USD: 1.0, FIELD_GUEST_DAILY_USD: 0.05, FIELD_ADMIN_DAILY_USD: 0.0}
cfg = {
FIELD_USER_DAILY_USD: 1.0,
FIELD_GUEST_DAILY_USD: 0.05,
FIELD_ADMIN_DAILY_USD: 0.0,
}
assert effective_daily_limit(cfg, "user", False) == 1.0
assert effective_daily_limit(cfg, "guest", False) == 0.05
assert effective_daily_limit(cfg, "user", True) == 0.0
@@ -57,8 +76,10 @@ def test_effective_limit_resolver():
# ---------- Admin exempt by default; user cap still enforced ----------
def test_admin_exempt_by_default(local_db):
from devplacepy.routers.profile import _ai_quota
_ensure_devii()
set_setting("devii_user_daily_usd", "1.0")
set_setting("devii_admin_daily_usd", "0.0")
@@ -86,8 +107,10 @@ def test_admin_limit_is_zero_by_default(local_db):
# ---------- Admin and guest caps are configurable ----------
def test_admin_cap_configurable(local_db):
from devplacepy.routers.profile import _ai_quota
_ensure_devii()
set_setting("devii_admin_daily_usd", "2.0")
try:
@@ -110,6 +133,7 @@ def test_guest_cap_configurable(local_db):
# ---------- Resetting quotas (the ledger drives the rolling 24h spend) ----------
def test_reset_single_owner(local_db):
ledger = UsageLedger()
uid, _ = _make_user()
@@ -144,8 +168,10 @@ def test_reset_all(local_db):
# ---------- CLI: devplace devii reset-quota ----------
def test_cli_reset_quota_for_user(local_db):
from devplacepy.cli import cmd_devii_reset_quota
uid, username = _make_user()
_seed_ledger("user", uid, 0.5, n=2)
cmd_devii_reset_quota(SimpleNamespace(username=username, guests=False, all=False))
@@ -154,6 +180,7 @@ def test_cli_reset_quota_for_user(local_db):
def test_cli_reset_quota_guests(local_db):
from devplacepy.cli import cmd_devii_reset_quota
guest_id = f"guest-{generate_uid()[:8]}"
user_id, _ = _make_user()
_seed_ledger("guest", guest_id, 0.02, n=2)
@@ -165,6 +192,7 @@ def test_cli_reset_quota_guests(local_db):
def test_cli_reset_quota_all(local_db):
from devplacepy.cli import cmd_devii_reset_quota
guest_id = f"guest-{generate_uid()[:8]}"
user_id, _ = _make_user()
_seed_ledger("guest", guest_id, 0.02, n=1)
@@ -175,11 +203,18 @@ def test_cli_reset_quota_all(local_db):
# ---------- Admin HTTP endpoints (end-to-end against the running app) ----------
def test_admin_reset_user_quota_endpoint(alice):
page, _ = alice
bob = get_table("users").find_one(username="bob_test")
get_table(LEDGER).insert(
{"owner_kind": "user", "owner_id": bob["uid"], "created_at": _now_iso(), "cost_usd": 0.5})
{
"owner_kind": "user",
"owner_id": bob["uid"],
"created_at": _now_iso(),
"cost_usd": 0.5,
}
)
resp = page.request.post(f"{BASE_URL}/admin/users/{bob['uid']}/reset-ai-quota")
assert resp.ok
assert get_table(LEDGER).count(owner_kind="user", owner_id=bob["uid"]) == 0
@@ -198,7 +233,13 @@ def test_non_admin_cannot_reset_quota(bob):
page, _ = bob
marker = f"guest-{generate_uid()[:8]}"
get_table(LEDGER).insert(
{"owner_kind": "guest", "owner_id": marker, "created_at": _now_iso(), "cost_usd": 0.01})
{
"owner_kind": "guest",
"owner_id": marker,
"created_at": _now_iso(),
"cost_usd": 0.01,
}
)
page.request.post(f"{BASE_URL}/admin/ai-quota/reset-all")
assert get_table(LEDGER).count(owner_kind="guest", owner_id=marker) == 1
get_table(LEDGER).delete(owner_kind="guest", owner_id=marker)
+6 -1
View File
@@ -19,7 +19,12 @@ def _run(call, dispatcher=None):
def test_truncated_arguments_reported_not_dispatched():
dispatcher = _FakeDispatcher()
call = {"function": {"name": "project_write_file", "arguments": '{"path":"a.md","content":"# hi'}}
call = {
"function": {
"name": "project_write_file",
"arguments": '{"path":"a.md","content":"# hi',
}
}
out = _run(call, dispatcher)
assert out["error"] == "tool_input_truncated"
assert "one write tool call per turn" in out["message"]
+36 -11
View File
@@ -36,7 +36,12 @@ def test_docs_unknown_page_404(app_server):
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
assert (
page.locator(
".sidebar-card a.sidebar-link[href='/docs/authentication.html']"
).count()
== 1
)
def test_docs_auth_examples_use_logged_in_user(alice):
@@ -85,7 +90,10 @@ def test_docs_operator_pages_require_admin(seeded_db):
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
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:
@@ -119,8 +127,11 @@ def test_docs_search_page_loads(app_server):
def test_docs_search_ranks_relevant_results(seeded_db):
admin = get_table("users").find_one(username="alice_test")
vision = requests.get(f"{BASE_URL}/docs/search.html", params={"q": "vision image"},
headers={"X-API-KEY": admin["api_key"]})
vision = requests.get(
f"{BASE_URL}/docs/search.html",
params={"q": "vision image"},
headers={"X-API-KEY": admin["api_key"]},
)
assert vision.status_code == 200
assert "/docs/gateway.html" in vision.text
assert "<mark>" in vision.text
@@ -131,12 +142,17 @@ def test_docs_search_ranks_relevant_results(seeded_db):
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"})
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"]})
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"])
@@ -167,7 +183,9 @@ def test_docs_download_includes_admin_pages_for_admin(seeded_db):
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"]})
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"])
@@ -204,7 +222,10 @@ def test_docs_pages_inject_runtime_context(app_server):
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}
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):
@@ -233,7 +254,9 @@ def test_docs_panel_format_picker_defaults_json(page, app_server):
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
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
@@ -247,7 +270,9 @@ def test_docs_panel_html_format_updates_snippet(page, app_server):
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)
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):
+9 -3
View File
@@ -43,13 +43,17 @@ def test_create_poll_and_vote(alice):
page.locator(".poll-option").first.click()
expect(page.locator(".poll-option").first).to_have_class(re.compile(r"\bchosen\b"))
expect(page.locator(".poll-option").first.locator(".poll-option-pct")).to_be_visible()
expect(
page.locator(".poll-option").first.locator(".poll-option-pct")
).to_be_visible()
def test_remove_poll_does_not_create_poll(alice):
page, _ = alice
_open_composer(page)
page.fill("#post-content", "Post where the poll is added then removed before posting.")
page.fill(
"#post-content", "Post where the poll is added then removed before posting."
)
page.locator("[data-poll-toggle]").click()
page.fill("#create-post-modal input[name='poll_question']", "Discarded question?")
options = page.locator("#create-post-modal input[name='poll_options']")
@@ -93,4 +97,6 @@ def test_reaction_palette_toggle_and_react(alice):
page.locator(".reaction-palette-btn").first.click()
expect(page.locator(".reaction-chip.reacted").first).to_be_visible()
expect(page.locator(".reaction-chip.reacted").first.locator(".reaction-count")).to_have_text("1")
expect(
page.locator(".reaction-chip.reacted").first.locator(".reaction-count")
).to_have_text("1")
+71 -29
View File
@@ -12,47 +12,83 @@ from devplacepy.utils import make_combined_slug
def _seed_posts(count):
owner = str(uuid4())
topic = f"pag{owner[:8]}"
get_table("users").insert({
"uid": owner, "username": f"pag_{owner[:8]}", "email": f"{owner[:8]}@test.devplace",
"password_hash": "x", "role": "Member", "is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
})
get_table("users").insert(
{
"uid": owner,
"username": f"pag_{owner[:8]}",
"email": f"{owner[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
posts = get_table("posts")
for i in range(count):
uid = str(uuid4())
content = f"Paginated post {i}"
posts.insert({
"uid": uid, "user_uid": owner, "slug": make_combined_slug(content, uid),
"title": None, "content": content, "topic": topic, "project_uid": None,
"image": None, "stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
})
posts.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(content, uid),
"title": None,
"content": content,
"topic": topic,
"project_uid": None,
"image": None,
"stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
}
)
return topic
def _seed_post_with_comments(comment_texts, topic="devlog"):
owner = str(uuid4())
get_table("users").insert({
"uid": owner, "username": f"seed_{owner[:8]}", "email": f"{owner[:8]}@seed.devplace",
"password_hash": "x", "role": "Member", "is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
})
get_table("users").insert(
{
"uid": owner,
"username": f"seed_{owner[:8]}",
"email": f"{owner[:8]}@seed.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
post_uid = str(uuid4())
marker = f"seedpost-{post_uid[:8]}"
get_table("posts").insert({
"uid": post_uid, "user_uid": owner, "slug": make_combined_slug(marker, post_uid),
"title": None, "content": marker, "topic": topic, "project_uid": None,
"image": None, "stars": 0, "created_at": datetime.now(timezone.utc).isoformat(),
})
get_table("posts").insert(
{
"uid": post_uid,
"user_uid": owner,
"slug": make_combined_slug(marker, post_uid),
"title": None,
"content": marker,
"topic": topic,
"project_uid": None,
"image": None,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
comments = get_table("comments")
base = datetime.now(timezone.utc)
for i, text in enumerate(comment_texts):
comments.insert({
"uid": str(uuid4()), "target_type": "post", "target_uid": post_uid, "post_uid": post_uid,
"user_uid": owner, "content": text, "parent_uid": None,
"created_at": (base + timedelta(seconds=i)).isoformat(),
})
comments.insert(
{
"uid": str(uuid4()),
"target_type": "post",
"target_uid": post_uid,
"post_uid": post_uid,
"user_uid": owner,
"content": text,
"parent_uid": None,
"created_at": (base + timedelta(seconds=i)).isoformat(),
}
)
return marker, post_uid
@@ -77,7 +113,9 @@ def test_feed_vote_voted_state_persists(alice):
page.locator(".post-action-btn.vote-up").first.click()
expect(page.locator(".post-vote-count").first).to_have_text("1")
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
expect(page.locator(".post-action-btn.vote-up").first).to_have_class(re.compile(r"\bvoted\b"))
expect(page.locator(".post-action-btn.vote-up").first).to_have_class(
re.compile(r"\bvoted\b")
)
def test_feed_card_share_button(alice):
@@ -233,7 +271,9 @@ def test_feed_inline_comment(alice):
inline_form.locator("textarea[name='content']").fill("Inline comment from feed")
inline_form.locator("button.comment-form-submit").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
expect(page.locator(".comment-text:has-text('Inline comment from feed')")).to_be_visible()
expect(
page.locator(".comment-text:has-text('Inline comment from feed')")
).to_be_visible()
def test_feed_inline_comment_placeholder(alice):
@@ -249,7 +289,9 @@ def test_feed_inline_comment_placeholder(alice):
def test_feed_shows_last_three_comments(page, app_server):
m = uuid4().hex[:6]
marker, _ = _seed_post_with_comments([f"{m}-one", f"{m}-two", f"{m}-three", f"{m}-four"])
marker, _ = _seed_post_with_comments(
[f"{m}-one", f"{m}-two", f"{m}-three", f"{m}-four"]
)
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
card = page.locator(".post-card").filter(has_text=marker).first
card.wait_for(state="visible")
+10 -4
View File
@@ -12,10 +12,16 @@ def _session():
_counter[0] += 1
name = f"flw{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": "secret123", "confirm_password": "secret123",
}, allow_redirects=True)
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
+38 -20
View File
@@ -19,7 +19,9 @@ def _init_db():
@pytest.fixture
def fork_env(tmp_path, monkeypatch):
monkeypatch.setattr("devplacepy.services.jobs.fork_service.STAGING_DIR", tmp_path / "staging")
monkeypatch.setattr(
"devplacepy.services.jobs.fork_service.STAGING_DIR", tmp_path / "staging"
)
monkeypatch.setattr("devplacepy.project_files.PROJECT_FILES_DIR", tmp_path / "pf")
yield tmp_path
jobs = get_table("jobs")
@@ -53,21 +55,25 @@ def _make_source_project(*, is_private=False, binary=False):
pid = f"forktest-{_counter[0]}"
owner_uid = f"forktest-owner-{_counter[0]}"
user = {"uid": owner_uid, "username": f"forktester{_counter[0]}"}
get_table("users").insert({"uid": owner_uid, "username": user["username"], "xp": 0, "level": 1})
get_table("projects").insert({
"uid": pid,
"user_uid": owner_uid,
"slug": f"{pid}-source",
"title": "Source Project",
"description": "the original",
"project_type": "software",
"platforms": "linux",
"status": "Released",
"is_private": 1 if is_private else 0,
"read_only": 0,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
})
get_table("users").insert(
{"uid": owner_uid, "username": user["username"], "xp": 0, "level": 1}
)
get_table("projects").insert(
{
"uid": pid,
"user_uid": owner_uid,
"slug": f"{pid}-source",
"title": "Source Project",
"description": "the original",
"project_type": "software",
"platforms": "linux",
"status": "Released",
"is_private": 1 if is_private else 0,
"read_only": 0,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
project_files.write_text_file(pid, user, "README.md", "# hello\nworld")
project_files.write_text_file(pid, user, "src/app.py", "print(1)\n")
if binary:
@@ -90,10 +96,15 @@ def _process_fork_jobs():
for _ in range(400):
await svc.run_once()
refresh_snapshot()
pending = [r for r in get_table("jobs").find(kind="fork") if r["status"] in ("pending", "running")]
pending = [
r
for r in get_table("jobs").find(kind="fork")
if r["status"] in ("pending", "running")
]
if not pending and not svc._inflight:
return
await asyncio.sleep(0.05)
run_async(drive())
@@ -101,7 +112,9 @@ def _enqueue(source_uid, owner_uid, title="My Fork"):
return queue.enqueue(
"fork",
{"source_project_uid": source_uid, "title": title, "forked_by_uid": owner_uid},
"user", owner_uid, title,
"user",
owner_uid,
title,
)
@@ -190,7 +203,9 @@ def test_retention_sweep_keeps_project(fork_env):
uid = _enqueue(pid, owner_uid)
_process_fork_jobs()
new_uid = queue.get_job(uid)["result"]["project_uid"]
get_table("jobs").update({"uid": uid, "expires_at": "2000-01-01T00:00:00+00:00"}, ["uid"])
get_table("jobs").update(
{"uid": uid, "expires_at": "2000-01-01T00:00:00+00:00"}, ["uid"]
)
svc = ForkService()
run_async(svc.run_once())
refresh_snapshot()
@@ -200,7 +215,10 @@ def test_retention_sweep_keeps_project(fork_env):
def test_orphan_running_recovered_on_enable(fork_env):
uid = _enqueue("p", "forktest-owner-o")
get_table("jobs").update({"uid": uid, "status": "running", "started_at": "2020-01-01T00:00:00+00:00"}, ["uid"])
get_table("jobs").update(
{"uid": uid, "status": "running", "started_at": "2020-01-01T00:00:00+00:00"},
["uid"],
)
svc = ForkService()
run_async(svc.on_enable())
job = queue.get_job(uid)
+86 -32
View File
@@ -22,44 +22,69 @@ def _make_source(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)
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())
get_table("users").insert({
"uid": owner, "username": f"pag_{owner[:8]}", "email": f"{owner[:8]}@test.devplace",
"password_hash": "x", "role": "Member", "is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
})
get_table("users").insert(
{
"uid": owner,
"username": f"pag_{owner[:8]}",
"email": f"{owner[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
gists = get_table("gists")
for i in range(count):
uid = str(uuid4())
title = f"Pag Gist {i}"
gists.insert({
"uid": uid, "user_uid": owner, "slug": make_combined_slug(title, uid),
"title": title, "language": "python", "description": "paginated", "stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
})
gists.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(title, uid),
"title": title,
"language": "python",
"description": "paginated",
"stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
}
)
return owner
def _set_cm_value(page, value):
page.evaluate(f'''() => {{
page.evaluate(f"""() => {{
const cm = document.querySelector(".CodeMirror")?.CodeMirror;
if (cm) {{
cm.setValue({repr(value)});
cm.save();
}}
}}''')
}}""")
def _create_gist(page, title="Test Gist", description="Test description", language="python", source_code="print('hello')"):
def _create_gist(
page,
title="Test Gist",
description="Test description",
language="python",
source_code="print('hello')",
):
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()
@@ -87,7 +112,9 @@ def test_gist_listing_loads(page, app_server):
def test_gist_listing_empty(page, app_server):
# 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")
page.goto(
f"{BASE_URL}/gists?user_uid=no-such-user-xyz", wait_until="domcontentloaded"
)
assert page.is_visible("text=No gists found")
@@ -114,7 +141,12 @@ def test_create_gist(alice, app_server):
def test_gist_detail_shows_all_sections(alice, app_server):
page, _ = alice
title = f"Detail Test {int(time.time())}"
_create_gist(page, title=title, description="See all sections", source_code="def hello(): pass")
_create_gist(
page,
title=title,
description="See all sections",
source_code="def hello(): pass",
)
assert page.is_visible("text=See all sections")
assert page.is_visible("text=def hello(): pass")
assert page.is_visible("text=Copy")
@@ -200,6 +232,7 @@ def test_gist_detail_share_button(alice, app_server):
def test_gist_code_copy_button(alice, app_server):
from playwright.sync_api import expect
page, _ = alice
_create_gist(page, title=f"Copy Gist {int(time.time())}", source_code="copyme = 42")
btn = page.locator("button[data-copy='gist-code-content']")
@@ -211,7 +244,10 @@ def test_profile_gists_tab(alice, app_server):
page, alice_user = alice
title = f"Profile Tab Test {int(time.time())}"
_create_gist(page, title=title, source_code="profile_tab = True")
page.goto(f"{BASE_URL}/profile/{alice_user['username']}?tab=gists", wait_until="domcontentloaded")
page.goto(
f"{BASE_URL}/profile/{alice_user['username']}?tab=gists",
wait_until="domcontentloaded",
)
assert page.is_visible(f"text={title}")
@@ -288,10 +324,16 @@ def test_create_triple_dpbot_gist_saves(app_server):
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)
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)
@@ -304,10 +346,16 @@ 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)
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
@@ -317,10 +365,16 @@ 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)
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
+1
View File
@@ -28,6 +28,7 @@ def test_profile_rank_stat(alice):
def test_leaderboard_ranks_after_upvote(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
+6 -2
View File
@@ -6,7 +6,9 @@ from devplacepy.database import get_table
def test_send_message_appears_in_thread(alice):
page, _ = alice
bob = get_table("users").find_one(username="bob_test")
page.goto(f"{BASE_URL}/messages?with_uid={bob['uid']}", wait_until="domcontentloaded")
page.goto(
f"{BASE_URL}/messages?with_uid={bob['uid']}", wait_until="domcontentloaded"
)
msg = f"Hello bob {int(time.time() * 1000)}"
page.fill("input[name='content']", msg)
page.locator(".messages-send-btn").click()
@@ -31,7 +33,9 @@ def test_messages_search_input(alice):
def test_messages_empty_state(alice):
page, _ = alice
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")
assert page.is_visible(".messages-layout") or page.is_visible("text=No conversations yet")
assert page.is_visible(".messages-layout") or page.is_visible(
"text=No conversations yet"
)
def test_messages_search_for_bob(alice):
+29 -16
View File
@@ -15,13 +15,21 @@ def _seed_news_paginated(count):
uid = str(uuid4())
title = f"Pag News {marker} {i:02d}"
titles.append(title)
news_table.insert({
"uid": uid, "slug": make_combined_slug(title, uid), "title": title,
"external_id": f"pag_{marker}_{i}", "grade": 10,
"status": "published", "show_on_landing": 0, "source_name": "PagSource",
"url": "https://example.com", "description": "paginated news",
"synced_at": (base - timedelta(seconds=i)).isoformat(),
})
news_table.insert(
{
"uid": uid,
"slug": make_combined_slug(title, uid),
"title": title,
"external_id": f"pag_{marker}_{i}",
"grade": 10,
"status": "published",
"show_on_landing": 0,
"source_name": "PagSource",
"url": "https://example.com",
"description": "paginated news",
"synced_at": (base - timedelta(seconds=i)).isoformat(),
}
)
return titles
@@ -40,14 +48,20 @@ def seed_news():
uid = str(uuid4())
title = f"News Test Article {i}"
slug = make_combined_slug(title, uid)
news_table.insert({
"uid": uid, "slug": slug, "title": title,
"external_id": eid, "grade": 8 + i,
"status": "published", "show_on_landing": 1,
"source_name": "TestSource",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": f"Description for test article {i}.",
})
news_table.insert(
{
"uid": uid,
"slug": slug,
"title": title,
"external_id": eid,
"grade": 8 + i,
"status": "published",
"show_on_landing": 1,
"source_name": "TestSource",
"synced_at": datetime.now(timezone.utc).isoformat(),
"description": f"Description for test article {i}.",
}
)
@pytest.fixture
@@ -117,4 +131,3 @@ def test_news_detail_guest(page, news_article):
assert page.is_visible("text=News Test Article")
assert page.is_visible("text=Comments")
assert page.is_visible("text=Read on TestSource")
+104 -24
View File
@@ -2,7 +2,12 @@ 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.services.news import (
NewsService,
_extract_grade,
_get_ai_key,
_get_article_images,
)
from devplacepy.database import get_table
from devplacepy.utils import generate_uid
from tests.conftest import run_async
@@ -49,7 +54,9 @@ class FakeClient:
if "EmptyArticle" in prompt:
return FakeResp(json_data={"choices": [{"message": {"content": ""}}]})
if "BadArticle" in prompt:
return FakeResp(json_data={"choices": [{"message": {"content": "no number"}}]})
return FakeResp(
json_data={"choices": [{"message": {"content": "no number"}}]}
)
grade = "9" if "HighArticle" in prompt else "3"
return FakeResp(json_data={"choices": [{"message": {"content": grade}}]})
@@ -70,6 +77,7 @@ def _settings_stub(threshold="7"):
"news_grade_threshold": threshold,
"news_ai_key": "",
}.get(key, default)
return fake_get_setting
@@ -90,12 +98,17 @@ def test_get_ai_key_env_precedence(monkeypatch):
def test_get_ai_key_setting_fallback(monkeypatch):
monkeypatch.delenv("NEWS_AI_KEY", raising=False)
monkeypatch.setattr(news_mod, "get_setting", lambda key, default=None: "from-setting" if key == "news_ai_key" else default)
monkeypatch.setattr(
news_mod,
"get_setting",
lambda key, default=None: "from-setting" if key == "news_ai_key" else default,
)
assert _get_ai_key() == "from-setting"
def test_get_ai_key_internal_gateway_fallback(local_db, monkeypatch):
from devplacepy.database import internal_gateway_key
monkeypatch.delenv("NEWS_AI_KEY", raising=False)
monkeypatch.setattr(news_mod, "get_setting", _settings_stub())
assert _get_ai_key() == internal_gateway_key()
@@ -103,37 +116,70 @@ def test_get_ai_key_internal_gateway_fallback(local_db, monkeypatch):
def test_grade_article_empty_content_returns_none(local_db, monkeypatch):
monkeypatch.setattr(news_mod, "get_setting", _settings_stub())
grade = run_async(NewsService()._grade_article(
{"title": "EmptyArticle", "description": "d", "content": "c"}, AI_URL, "m", FakeClient([])))
grade = run_async(
NewsService()._grade_article(
{"title": "EmptyArticle", "description": "d", "content": "c"},
AI_URL,
"m",
FakeClient([]),
)
)
assert grade is None
def test_grade_article_unparseable_returns_none(local_db, monkeypatch):
monkeypatch.setattr(news_mod, "get_setting", _settings_stub())
grade = run_async(NewsService()._grade_article(
{"title": "BadArticle", "description": "d", "content": "c"}, AI_URL, "m", FakeClient([])))
grade = run_async(
NewsService()._grade_article(
{"title": "BadArticle", "description": "d", "content": "c"},
AI_URL,
"m",
FakeClient([]),
)
)
assert grade is None
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([]))
monkeypatch.setattr(
news_mod.httpx, "AsyncClient", lambda *a, **k: FailingApiClient([])
)
run_async(NewsService().run_once())
def test_run_once_updates_existing_news_row(local_db, monkeypatch):
external_id = f"news-{generate_uid()}"
existing_uid = generate_uid()
get_table("news").insert({
"uid": existing_uid, "external_id": external_id, "slug": "",
"title": "Old Title", "status": "draft", "grade": 0, "synced_at": "2020-01-01",
})
articles = [{"guid": external_id, "title": "HighArticle", "description": "d", "content": "c",
"link": "", "feed_name": "Feed", "author": "A", "published": "2026-01-01"}]
get_table("news").insert(
{
"uid": existing_uid,
"external_id": external_id,
"slug": "",
"title": "Old Title",
"status": "draft",
"grade": 0,
"synced_at": "2020-01-01",
}
)
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))
monkeypatch.setattr(
news_mod.httpx, "AsyncClient", lambda *a, **k: FakeClient(articles)
)
run_async(NewsService().run_once())
@@ -173,18 +219,52 @@ def test_get_article_images_network_error_returns_empty():
def test_run_once_publishes_grades_and_is_idempotent(local_db, monkeypatch):
g_high, g_low, g_fail = (f"news-{generate_uid()}" for _ in range(3))
articles = [
{"guid": g_high, "title": "HighArticle", "description": "d", "content": "c",
"link": LINK_HIGH, "feed_name": "Feed", "author": "A", "published": "2026-01-01"},
{"guid": g_low, "title": "LowArticle", "description": "d", "content": "c",
"link": LINK_LOW, "feed_name": "Feed", "author": "A", "published": "2026-01-01"},
{"guid": g_fail, "title": "FailArticle", "description": "d", "content": "c",
"link": "", "feed_name": "Feed", "author": "A", "published": "2026-01-01"},
{"guid": "", "title": "NoGuid", "description": "d", "content": "c",
"link": "", "feed_name": "Feed", "author": "A", "published": "2026-01-01"},
{
"guid": g_high,
"title": "HighArticle",
"description": "d",
"content": "c",
"link": LINK_HIGH,
"feed_name": "Feed",
"author": "A",
"published": "2026-01-01",
},
{
"guid": g_low,
"title": "LowArticle",
"description": "d",
"content": "c",
"link": LINK_LOW,
"feed_name": "Feed",
"author": "A",
"published": "2026-01-01",
},
{
"guid": g_fail,
"title": "FailArticle",
"description": "d",
"content": "c",
"link": "",
"feed_name": "Feed",
"author": "A",
"published": "2026-01-01",
},
{
"guid": "",
"title": "NoGuid",
"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))
monkeypatch.setattr(
news_mod.httpx, "AsyncClient", lambda *a, **k: FakeClient(articles)
)
run_async(NewsService().run_once())
+129 -51
View File
@@ -12,16 +12,18 @@ def test_notifications_pagination(alice):
now = datetime.now(timezone.utc)
for i in range(30):
notifications_table.insert({
"uid": generate_uid(),
"user_uid": alice_row["uid"],
"type": "test",
"message": f"pagination-test-msg-{i:02d}",
"related_uid": alice_row["uid"],
"target_url": "/feed",
"read": False,
"created_at": (now - timedelta(seconds=i)).isoformat(),
})
notifications_table.insert(
{
"uid": generate_uid(),
"user_uid": alice_row["uid"],
"type": "test",
"message": f"pagination-test-msg-{i:02d}",
"related_uid": alice_row["uid"],
"target_url": "/feed",
"read": False,
"created_at": (now - timedelta(seconds=i)).isoformat(),
}
)
page.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
cards = page.locator(".notification-card")
@@ -35,7 +37,9 @@ def test_notifications_pagination(alice):
page.goto(f"{BASE_URL}{href}", wait_until="domcontentloaded")
cards2 = page.locator(".notification-card")
assert cards2.count() == 5, f"expected 5 cards on page 2, got {cards2.count()}"
assert page.locator(".load-more-wrap").count() == 0, "Load More should be absent on final page"
assert page.locator(".load-more-wrap").count() == 0, (
"Load More should be absent on final page"
)
notifications_table.delete(user_uid=alice_row["uid"])
@@ -48,16 +52,18 @@ def test_notifications_no_load_more_when_under_page_size(alice):
now = datetime.now(timezone.utc)
for i in range(3):
notifications_table.insert({
"uid": generate_uid(),
"user_uid": alice_row["uid"],
"type": "test",
"message": f"small-set-msg-{i}",
"related_uid": alice_row["uid"],
"target_url": "/feed",
"read": False,
"created_at": (now - timedelta(seconds=i)).isoformat(),
})
notifications_table.insert(
{
"uid": generate_uid(),
"user_uid": alice_row["uid"],
"type": "test",
"message": f"small-set-msg-{i}",
"related_uid": alice_row["uid"],
"target_url": "/feed",
"read": False,
"created_at": (now - timedelta(seconds=i)).isoformat(),
}
)
page.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
assert page.locator(".notification-card").count() == 3
@@ -98,7 +104,9 @@ def test_notifications_bell_visible(alice):
def test_notifications_empty_state(alice):
page, _ = alice
page.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
assert page.is_visible("h2:has-text('Notifications')") or page.is_visible("text=No notifications yet")
assert page.is_visible("h2:has-text('Notifications')") or page.is_visible(
"text=No notifications yet"
)
def test_notifications_header_has_clear(alice):
@@ -123,6 +131,7 @@ def test_notifications_avatar_visible(alice):
def test_vote_notification_on_post(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
@@ -150,13 +159,19 @@ def test_vote_notification_on_post(app_server, browser, seeded_db):
pa.wait_for_timeout(500)
pa_body = pa.locator("body").text_content()
assert "Internal Server Error" not in pa_body, f"Alice got 500 after vote: {pa_body[:300]}"
assert "Internal Server Error" not in pa_body, (
f"Alice got 500 after vote: {pa_body[:300]}"
)
pb.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
pb.wait_for_timeout(1500)
body = pb.locator("body").text_content()
assert "Internal Server Error" not in body, f"Got 500 error on notifications page: {body[:500]}"
assert "alice_test" in body, f"Expected 'alice_test' in notifications, got: {body[:500]}"
assert "Internal Server Error" not in body, (
f"Got 500 error on notifications page: {body[:500]}"
)
assert "alice_test" in body, (
f"Expected 'alice_test' in notifications, got: {body[:500]}"
)
assert "++'d" in body, f"Expected '++\\'d' in notifications, got: {body[:500]}"
ctx_a.close()
@@ -165,6 +180,7 @@ def test_vote_notification_on_post(app_server, browser, seeded_db):
def test_comment_notification_on_post(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
@@ -197,9 +213,15 @@ def test_comment_notification_on_post(app_server, browser, seeded_db):
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
pa.wait_for_timeout(1500)
body = pa.locator("body").text_content()
assert "Internal Server Error" not in body, f"Got 500 error on notifications: {body[:500]}"
assert "bob_test" in body, f"Expected 'bob_test' in notifications, got: {body[:500]}"
assert "commented on your" in body, f"Expected 'commented on your' in notifications, got: {body[:500]}"
assert "Internal Server Error" not in body, (
f"Got 500 error on notifications: {body[:500]}"
)
assert "bob_test" in body, (
f"Expected 'bob_test' in notifications, got: {body[:500]}"
)
assert "commented on your" in body, (
f"Expected 'commented on your' in notifications, got: {body[:500]}"
)
ctx_a.close()
ctx_b.close()
@@ -207,6 +229,7 @@ def test_comment_notification_on_post(app_server, browser, seeded_db):
def test_follow_notification(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
@@ -230,9 +253,15 @@ def test_follow_notification(app_server, browser, seeded_db):
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
pa.wait_for_timeout(1500)
body = pa.locator("body").text_content()
assert "Internal Server Error" not in body, f"Got 500 error on notifications: {body[:500]}"
assert "bob_test" in body, f"Expected 'bob_test' in notifications, got: {body[:500]}"
assert "started following you" in body, f"Expected 'started following you' in notifications, got: {body[:500]}"
assert "Internal Server Error" not in body, (
f"Got 500 error on notifications: {body[:500]}"
)
assert "bob_test" in body, (
f"Expected 'bob_test' in notifications, got: {body[:500]}"
)
assert "started following you" in body, (
f"Expected 'started following you' in notifications, got: {body[:500]}"
)
ctx_a.close()
ctx_b.close()
@@ -240,6 +269,7 @@ def test_follow_notification(app_server, browser, seeded_db):
def test_message_notification(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
@@ -265,9 +295,15 @@ def test_message_notification(app_server, browser, seeded_db):
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
pa.wait_for_timeout(1500)
body = pa.locator("body").text_content()
assert "Internal Server Error" not in body, f"Got 500 error on notifications: {body[:500]}"
assert "bob_test" in body, f"Expected 'bob_test' in notifications, got: {body[:500]}"
assert "sent you a message" in body, f"Expected 'sent you a message' in notifications, got: {body[:500]}"
assert "Internal Server Error" not in body, (
f"Got 500 error on notifications: {body[:500]}"
)
assert "bob_test" in body, (
f"Expected 'bob_test' in notifications, got: {body[:500]}"
)
assert "sent you a message" in body, (
f"Expected 'sent you a message' in notifications, got: {body[:500]}"
)
ctx_a.close()
ctx_b.close()
@@ -275,6 +311,7 @@ def test_message_notification(app_server, browser, seeded_db):
def test_mention_notification_in_post(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
@@ -295,9 +332,15 @@ def test_mention_notification_in_post(app_server, browser, seeded_db):
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
pa.wait_for_timeout(2000)
body = pa.locator("body").text_content()
assert "Internal Server Error" not in body, f"Got 500 error on notifications: {body[:500]}"
assert "bob_test" in body, f"Expected 'bob_test' in notifications for mention, got: {body[:500]}"
assert "mentioned you" in body, f"Expected 'mentioned you' in notifications, got: {body[:500]}"
assert "Internal Server Error" not in body, (
f"Got 500 error on notifications: {body[:500]}"
)
assert "bob_test" in body, (
f"Expected 'bob_test' in notifications for mention, got: {body[:500]}"
)
assert "mentioned you" in body, (
f"Expected 'mentioned you' in notifications, got: {body[:500]}"
)
ctx_a.close()
ctx_b.close()
@@ -305,6 +348,7 @@ def test_mention_notification_in_post(app_server, browser, seeded_db):
def test_mention_notification_in_comment(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
@@ -337,9 +381,15 @@ def test_mention_notification_in_comment(app_server, browser, seeded_db):
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
pa.wait_for_timeout(2000)
body = pa.locator("body").text_content()
assert "Internal Server Error" not in body, f"Got 500 error on notifications: {body[:500]}"
assert "bob_test" in body, f"Expected 'bob_test' in notifications for mention, got: {body[:500]}"
assert "mentioned you" in body, f"Expected 'mentioned you' in notifications, got: {body[:500]}"
assert "Internal Server Error" not in body, (
f"Got 500 error on notifications: {body[:500]}"
)
assert "bob_test" in body, (
f"Expected 'bob_test' in notifications for mention, got: {body[:500]}"
)
assert "mentioned you" in body, (
f"Expected 'mentioned you' in notifications, got: {body[:500]}"
)
ctx_a.close()
ctx_b.close()
@@ -347,6 +397,7 @@ def test_mention_notification_in_comment(app_server, browser, seeded_db):
def test_reply_notification(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
@@ -391,9 +442,15 @@ def test_reply_notification(app_server, browser, seeded_db):
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
pa.wait_for_timeout(2000)
body = pa.locator("body").text_content()
assert "Internal Server Error" not in body, f"Got 500 error on notifications: {body[:500]}"
assert "bob_test" in body, f"Expected 'bob_test' in notifications, got: {body[:500]}"
assert "replied to your comment" in body, f"Expected 'replied to your comment' in notifications, got: {body[:500]}"
assert "Internal Server Error" not in body, (
f"Got 500 error on notifications: {body[:500]}"
)
assert "bob_test" in body, (
f"Expected 'bob_test' in notifications, got: {body[:500]}"
)
assert "replied to your comment" in body, (
f"Expected 'replied to your comment' in notifications, got: {body[:500]}"
)
ctx_a.close()
ctx_b.close()
@@ -401,6 +458,7 @@ def test_reply_notification(app_server, browser, seeded_db):
def test_mention_notification_names_actor(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
@@ -421,8 +479,12 @@ def test_mention_notification_names_actor(app_server, browser, seeded_db):
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
pa.wait_for_timeout(1500)
texts = pa.locator(".notification-text").all_text_contents()
assert any("@bob_test mentioned you" in t for t in texts), f"mention message must name the actor (bob): {texts}"
assert not any("@alice_test mentioned you" in t for t in texts), f"mention message must not name the mentioned user (alice): {texts}"
assert any("@bob_test mentioned you" in t for t in texts), (
f"mention message must name the actor (bob): {texts}"
)
assert not any("@alice_test mentioned you" in t for t in texts), (
f"mention message must not name the mentioned user (alice): {texts}"
)
ctx_a.close()
ctx_b.close()
@@ -430,6 +492,7 @@ def test_mention_notification_names_actor(app_server, browser, seeded_db):
def test_comment_notification_click_opens_comment(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
@@ -456,7 +519,9 @@ def test_comment_notification_click_opens_comment(app_server, browser, seeded_db
pb.wait_for_timeout(1500)
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
card = pa.locator(".notification-card").filter(has_text="commented on your post").first
card = (
pa.locator(".notification-card").filter(has_text="commented on your post").first
)
card.wait_for(state="visible", timeout=10000)
href = card.locator("a.card-link").get_attribute("href")
assert href.startswith("/notifications/open/"), f"unexpected href: {href}"
@@ -473,7 +538,9 @@ def test_comment_notification_click_opens_comment(app_server, browser, seeded_db
pa.wait_for_timeout(1000)
target = pa.locator(f'.notification-card:has(a.card-link[href="{href}"])')
target.wait_for(state="visible", timeout=10000)
assert "unread" not in (target.get_attribute("class") or ""), "opening a notification should mark it read"
assert "unread" not in (target.get_attribute("class") or ""), (
"opening a notification should mark it read"
)
ctx_a.close()
ctx_b.close()
@@ -481,6 +548,7 @@ def test_comment_notification_click_opens_comment(app_server, browser, seeded_db
def test_follow_notification_click_opens_profile(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
@@ -499,11 +567,15 @@ def test_follow_notification_click_opens_profile(app_server, browser, seeded_db)
pb.wait_for_timeout(1000)
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
card = pa.locator(".notification-card").filter(has_text="started following you").first
card = (
pa.locator(".notification-card").filter(has_text="started following you").first
)
card.wait_for(state="visible", timeout=10000)
card.locator("a.card-link").click()
pa.wait_for_url("**/profile/bob_test", timeout=10000, wait_until="domcontentloaded")
assert pa.url.endswith("/profile/bob_test"), f"follow notification should open the follower profile: {pa.url}"
assert pa.url.endswith("/profile/bob_test"), (
f"follow notification should open the follower profile: {pa.url}"
)
ctx_a.close()
ctx_b.close()
@@ -511,6 +583,7 @@ def test_follow_notification_click_opens_profile(app_server, browser, seeded_db)
def test_message_notification_click_opens_conversation(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
@@ -534,7 +607,9 @@ def test_message_notification_click_opens_conversation(app_server, browser, seed
card.wait_for(state="visible", timeout=10000)
card.locator("a.card-link").click()
pa.wait_for_url("**/messages**", timeout=10000, wait_until="domcontentloaded")
assert "with_uid=" in pa.url, f"message notification should open the conversation: {pa.url}"
assert "with_uid=" in pa.url, (
f"message notification should open the conversation: {pa.url}"
)
ctx_a.close()
ctx_b.close()
@@ -542,6 +617,7 @@ def test_message_notification_click_opens_conversation(app_server, browser, seed
def test_vote_notification_click_opens_target(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
@@ -572,7 +648,9 @@ def test_vote_notification_click_opens_target(app_server, browser, seeded_db):
card.wait_for(state="visible", timeout=10000)
card.locator("a.card-link").click()
pb.wait_for_url(f"**{post_path}", timeout=10000, wait_until="domcontentloaded")
assert post_path in pb.url, f"vote notification should open the voted post: {pb.url}"
assert post_path in pb.url, (
f"vote notification should open the voted post: {pb.url}"
)
ctx_a.close()
ctx_b.close()
+89 -27
View File
@@ -12,6 +12,7 @@ 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
@@ -44,13 +45,23 @@ class FakeClient:
async def send(self, request):
self.calls.append((request.url, request.json_body))
body = request.json_body or {}
return FakeResp(payload={"id": "x", "model": body.get("model"),
"choices": [{"message": {"content": "hi there"}}]})
return FakeResp(
payload={
"id": "x",
"model": body.get("model"),
"choices": [{"message": {"content": "hi there"}}],
}
)
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"}}]})
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})
@@ -63,23 +74,40 @@ 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": {}})
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,
})
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()
@@ -87,7 +115,14 @@ def test_model_is_forced(local_db, monkeypatch):
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, ("guest", "test"), "test"))
run_async(
rt.handle_chat(
{"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]},
cfg,
("guest", "test"),
"test",
)
)
assert rt._client.calls[-1][1]["model"] == "deepseek-chat"
@@ -98,10 +133,15 @@ def test_vision_rewrites_image_to_text(local_db, monkeypatch):
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"}},
]}]
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, ("guest", "test"), "test"))
sent = rt._client.calls[-1][1]["messages"][0]["content"]
assert isinstance(sent, str) and "vision" in sent.lower()
@@ -112,7 +152,14 @@ def test_streaming_emits_sse(local_db, monkeypatch):
svc = GatewayService()
cfg = svc.effective_config()
rt = svc.runtime()
resp = run_async(rt.handle_chat({"messages": [{"role": "user", "content": "hi"}], "stream": True}, cfg, ("guest", "test"), "test"))
resp = run_async(
rt.handle_chat(
{"messages": [{"role": "user", "content": "hi"}], "stream": True},
cfg,
("guest", "test"),
"test",
)
)
async def drain():
out = []
@@ -143,7 +190,10 @@ def test_authorize_admin_and_user_toggles(local_db):
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={"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")
@@ -162,6 +212,7 @@ def test_authorize_require_auth_off_is_open(local_db):
# ---------- 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)
@@ -176,21 +227,32 @@ def test_gateway_disabled_returns_503(alice):
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")
_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": []})
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"}]})
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"}]})
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="")
+10 -3
View File
@@ -3,7 +3,9 @@ import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table
DEFAULT_MAINTENANCE_MESSAGE = "DevPlace is undergoing scheduled maintenance. Please check back shortly."
DEFAULT_MAINTENANCE_MESSAGE = (
"DevPlace is undergoing scheduled maintenance. Please check back shortly."
)
OPERATIONAL_FIELDS = (
"rate_limit_per_minute",
@@ -46,7 +48,10 @@ def test_operational_settings_persist(alice):
maintenance_message="Custom maintenance text",
)
assert page.locator("#session_remember_days").input_value() == "14"
assert page.locator("#maintenance_message").input_value() == "Custom maintenance text"
assert (
page.locator("#maintenance_message").input_value()
== "Custom maintenance text"
)
finally:
_save_settings(
page,
@@ -63,7 +68,9 @@ def test_maintenance_mode_blocks_guests(alice):
assert response.status_code == 503
assert "Down for tests" in response.text
finally:
_save_settings(page, maintenance_mode="0", maintenance_message=DEFAULT_MAINTENANCE_MESSAGE)
_save_settings(
page, maintenance_mode="0", maintenance_message=DEFAULT_MAINTENANCE_MESSAGE
)
def test_maintenance_mode_admin_retains_access(alice):
+116 -51
View File
@@ -13,10 +13,16 @@ def _session():
_counter[0] += 1
name = f"pol{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": "secret123", "confirm_password": "secret123",
}, allow_redirects=True)
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
@@ -35,7 +41,9 @@ def _create_post(session, title, question, options):
def _poll_for(post_uid):
poll = get_table("polls").find_one(post_uid=post_uid)
options = list(get_table("poll_options").find(poll_uid=poll["uid"], order_by=["position"]))
options = list(
get_table("poll_options").find(poll_uid=poll["uid"], order_by=["position"])
)
return poll, options
@@ -67,7 +75,11 @@ def test_vote_records_choice(app_server):
title = f"vote-{int(time.time() * 1000)}"
post_uid = _create_post(s, title, "Pick one", ["A", "B"])
poll, options = _poll_for(post_uid)
r = s.post(f"{BASE_URL}/polls/{poll['uid']}/vote", data={"option_uid": options[0]["uid"]}, headers=AJAX)
r = s.post(
f"{BASE_URL}/polls/{poll['uid']}/vote",
data={"option_uid": options[0]["uid"]},
headers=AJAX,
)
payload = r.json()
assert payload["total"] == 1
assert payload["my_choice"] == options[0]["uid"]
@@ -79,8 +91,16 @@ def test_switch_vote_moves_choice(app_server):
title = f"switch-{int(time.time() * 1000)}"
post_uid = _create_post(s, title, "Pick one", ["A", "B"])
poll, options = _poll_for(post_uid)
s.post(f"{BASE_URL}/polls/{poll['uid']}/vote", data={"option_uid": options[0]["uid"]}, headers=AJAX)
r = s.post(f"{BASE_URL}/polls/{poll['uid']}/vote", data={"option_uid": options[1]["uid"]}, headers=AJAX)
s.post(
f"{BASE_URL}/polls/{poll['uid']}/vote",
data={"option_uid": options[0]["uid"]},
headers=AJAX,
)
r = s.post(
f"{BASE_URL}/polls/{poll['uid']}/vote",
data={"option_uid": options[1]["uid"]},
headers=AJAX,
)
payload = r.json()
assert payload["my_choice"] == options[1]["uid"]
assert payload["total"] == 1
@@ -91,8 +111,16 @@ def test_repeat_same_option_retracts(app_server):
title = f"retract-{int(time.time() * 1000)}"
post_uid = _create_post(s, title, "Pick one", ["A", "B"])
poll, options = _poll_for(post_uid)
s.post(f"{BASE_URL}/polls/{poll['uid']}/vote", data={"option_uid": options[0]["uid"]}, headers=AJAX)
r = s.post(f"{BASE_URL}/polls/{poll['uid']}/vote", data={"option_uid": options[0]["uid"]}, headers=AJAX)
s.post(
f"{BASE_URL}/polls/{poll['uid']}/vote",
data={"option_uid": options[0]["uid"]},
headers=AJAX,
)
r = s.post(
f"{BASE_URL}/polls/{poll['uid']}/vote",
data={"option_uid": options[0]["uid"]},
headers=AJAX,
)
payload = r.json()
assert payload["my_choice"] is None
assert payload["total"] == 0
@@ -104,7 +132,11 @@ def test_vote_invalid_option_returns_400(app_server):
title = f"badopt-{int(time.time() * 1000)}"
post_uid = _create_post(s, title, "Pick one", ["A", "B"])
poll, _ = _poll_for(post_uid)
r = s.post(f"{BASE_URL}/polls/{poll['uid']}/vote", data={"option_uid": "not-a-real-option"}, headers=AJAX)
r = s.post(
f"{BASE_URL}/polls/{poll['uid']}/vote",
data={"option_uid": "not-a-real-option"},
headers=AJAX,
)
assert r.status_code == 400
@@ -114,17 +146,26 @@ def test_vote_requires_login(app_server):
post_uid = _create_post(s, title, "Pick one", ["A", "B"])
poll, options = _poll_for(post_uid)
anon = requests.Session()
r = anon.post(f"{BASE_URL}/polls/{poll['uid']}/vote", data={"option_uid": options[0]["uid"]}, headers=AJAX, allow_redirects=False)
r = anon.post(
f"{BASE_URL}/polls/{poll['uid']}/vote",
data={"option_uid": options[0]["uid"]},
headers=AJAX,
allow_redirects=False,
)
assert r.status_code == 303
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)
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"]
@@ -132,13 +173,17 @@ def _create_plain_post(session, title):
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)
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)
@@ -149,13 +194,17 @@ def test_poll_from_comma_separated_string(app_server):
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)
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)
@@ -176,17 +225,25 @@ def test_add_poll_to_existing_post_via_edit(app_server):
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)
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"]
assert [option["label"] for option in options] == [
"Yes",
"Only on weekends",
"They are spiky",
]
def test_edit_does_not_replace_existing_poll(app_server):
@@ -194,13 +251,17 @@ def test_edit_does_not_replace_existing_poll(app_server):
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)
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?"
@@ -211,11 +272,15 @@ 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)
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
+61 -20
View File
@@ -57,8 +57,12 @@ def test_post_upvote_voted_state_persists(alice):
page.locator(".post-action-btn.vote-up").first.click()
expect(page.locator(".post-vote-count").first).to_have_text("1")
page.reload(wait_until="domcontentloaded")
expect(page.locator(".post-action-btn.vote-up").first).to_have_class(re.compile(r"\bvoted\b"))
assert "voted" not in (page.locator(".post-action-btn.vote-down").first.get_attribute("class") or "")
expect(page.locator(".post-action-btn.vote-up").first).to_have_class(
re.compile(r"\bvoted\b")
)
assert "voted" not in (
page.locator(".post-action-btn.vote-down").first.get_attribute("class") or ""
)
def test_post_downvote_voted_state_persists(alice):
@@ -67,8 +71,12 @@ def test_post_downvote_voted_state_persists(alice):
page.locator(".post-action-btn.vote-down").first.click()
expect(page.locator(".post-vote-count").first).to_have_text("-1")
page.reload(wait_until="domcontentloaded")
expect(page.locator(".post-action-btn.vote-down").first).to_have_class(re.compile(r"\bvoted\b"))
assert "voted" not in (page.locator(".post-action-btn.vote-up").first.get_attribute("class") or "")
expect(page.locator(".post-action-btn.vote-down").first).to_have_class(
re.compile(r"\bvoted\b")
)
assert "voted" not in (
page.locator(".post-action-btn.vote-up").first.get_attribute("class") or ""
)
def test_comment_voted_state_persists(alice):
@@ -77,16 +85,24 @@ def test_comment_voted_state_persists(alice):
textarea = page.locator(".comment-form textarea[name='content']")
textarea.fill("Comment whose vote should persist")
page.locator(".comment-form button:has-text('Post')").click()
expect(page.locator(".comment-text:has-text('Comment whose vote should persist')")).to_be_visible()
expect(
page.locator(".comment-text:has-text('Comment whose vote should persist')")
).to_be_visible()
page.locator(".comment-vote-btn").first.click()
expect(page.locator(".comment-vote-btn").first).to_have_class(re.compile(r"\bvoted\b"))
expect(page.locator(".comment-vote-btn").first).to_have_class(
re.compile(r"\bvoted\b")
)
page.reload(wait_until="domcontentloaded")
expect(page.locator(".comment-vote-btn").first).to_have_class(re.compile(r"\bvoted\b"))
expect(page.locator(".comment-vote-btn").first).to_have_class(
re.compile(r"\bvoted\b")
)
def _profile_stars(page, username):
page.goto(f"{BASE_URL}/profile/{username}", wait_until="domcontentloaded")
value = page.locator(".profile-stat:has(.profile-stat-label:has-text('Stars')) .profile-stat-value").first
value = page.locator(
".profile-stat:has(.profile-stat-label:has-text('Stars')) .profile-stat-value"
).first
return int(value.text_content().strip())
@@ -113,7 +129,9 @@ def test_add_comment(alice):
textarea = page.locator(".comment-form textarea[name='content']")
textarea.fill("This is a test comment from Playwright")
page.locator(".comment-form button:has-text('Post')").click()
expect(page.locator(".comment-text:has-text('This is a test comment from Playwright')")).to_be_visible()
expect(
page.locator(".comment-text:has-text('This is a test comment from Playwright')")
).to_be_visible()
def test_comment_voting(alice):
@@ -169,9 +187,13 @@ def test_multiple_comments_on_post(alice):
page, _ = alice
create_post(page, "devlog", "Post with many comments")
for i in range(3):
page.locator(".comment-form textarea[name='content']").fill(f"Comment number {i + 1}")
page.locator(".comment-form textarea[name='content']").fill(
f"Comment number {i + 1}"
)
page.locator(".comment-form button:has-text('Post')").click()
expect(page.locator(f".comment-text:has-text('Comment number {i + 1}')")).to_be_visible()
expect(
page.locator(f".comment-text:has-text('Comment number {i + 1}')")
).to_be_visible()
assert page.is_visible("text=Comment number 1")
assert page.is_visible("text=Comment number 3")
@@ -186,7 +208,9 @@ def test_comment_and_vote_then_delete(alice):
vote_up = page.locator(".comment-vote-btn").first
vote_up.click()
expect(page.locator(".comment-vote-btn").first).to_have_class(re.compile(r"\bvoted\b"))
expect(page.locator(".comment-vote-btn").first).to_have_class(
re.compile(r"\bvoted\b")
)
delete_btn = page.locator(".comment-action-btn:has-text('Delete')").last
expect(delete_btn).to_be_visible()
@@ -200,13 +224,17 @@ def test_comment_reply_inline_form(alice):
create_post(page, "devlog", "Post for inline reply")
page.locator(".comment-form textarea[name='content']").fill("Parent comment here")
page.locator(".comment-form button:has-text('Post')").click()
expect(page.locator(".comment-text:has-text('Parent comment here')")).to_be_visible()
expect(
page.locator(".comment-text:has-text('Parent comment here')")
).to_be_visible()
page.locator(".comment [data-action='reply']").first.click()
reply_form = page.locator(".comment-reply-form").first
expect(reply_form).to_be_visible()
reply_form.locator("textarea[name='content']").fill("Inline reply text")
reply_form.locator("button:has-text('Post')").click()
expect(page.locator(".comment-replies .comment-text:has-text('Inline reply text')")).to_be_visible()
expect(
page.locator(".comment-replies .comment-text:has-text('Inline reply text')")
).to_be_visible()
def test_comment_reply_toggle_and_cancel(alice):
@@ -231,11 +259,15 @@ def test_comment_create_scrolls_to_anchor(alice):
create_post(page, "devlog", "Post for comment anchor")
page.locator(".comment-form textarea[name='content']").fill("Anchor comment text")
page.locator(".comment-form button:has-text('Post')").click()
expect(page.locator(".comment-text:has-text('Anchor comment text')")).to_be_visible()
expect(
page.locator(".comment-text:has-text('Anchor comment text')")
).to_be_visible()
assert "#comment-" in page.url
comment_uid = page.url.split("#comment-")[1]
page.wait_for_selector(f"#comment-{comment_uid}.comment-highlight", timeout=5000)
expect(page.locator(f"#comment-{comment_uid}")).to_contain_text("Anchor comment text")
expect(page.locator(f"#comment-{comment_uid}")).to_contain_text(
"Anchor comment text"
)
def test_post_edit_button(alice):
@@ -299,7 +331,9 @@ def test_delete_own_post(alice):
def test_content_rendering_markdown_and_highlight(alice):
page, _ = alice
create_post(page, "random", "Hello **world bold** text\n\n```python\nprint('hi')\n```")
create_post(
page, "random", "Hello **world bold** text\n\n```python\nprint('hi')\n```"
)
page.locator(".rendered-content strong").first.wait_for(state="visible")
assert page.locator(".rendered-content pre code").count() >= 1
@@ -326,11 +360,18 @@ def test_emoji_picker_opens(alice):
def test_attachment_upload_ui(alice):
import io
from PIL import Image
page, _ = alice
create_post(page, "random", "Post for attachment upload UI")
buf = io.BytesIO()
Image.new("RGB", (4, 4), (0, 128, 255)).save(buf, "PNG")
file_input = page.locator(".comment-form dp-upload .dp-upload-input").first
file_input.set_input_files({"name": "pic.png", "mimeType": "image/png", "buffer": buf.getvalue()})
page.locator(".comment-form dp-upload .dp-upload-chip").first.wait_for(state="visible", timeout=15000)
expect(page.locator(".comment-form dp-upload input[name='attachment_uids']")).to_have_value(re.compile(r".+"))
file_input.set_input_files(
{"name": "pic.png", "mimeType": "image/png", "buffer": buf.getvalue()}
)
page.locator(".comment-form dp-upload .dp-upload-chip").first.wait_for(
state="visible", timeout=15000
)
expect(
page.locator(".comment-form dp-upload input[name='attachment_uids']")
).to_have_value(re.compile(r".+"))
+21 -6
View File
@@ -48,12 +48,18 @@ def test_profile_tabs(alice):
def test_profile_posts_tab(alice):
page, user = alice
page.goto(f"{BASE_URL}/profile/{user['username']}?tab=posts", wait_until="domcontentloaded")
page.goto(
f"{BASE_URL}/profile/{user['username']}?tab=posts",
wait_until="domcontentloaded",
)
def test_profile_projects_tab_content(alice):
page, user = alice
page.goto(f"{BASE_URL}/profile/{user['username']}?tab=projects", wait_until="domcontentloaded")
page.goto(
f"{BASE_URL}/profile/{user['username']}?tab=projects",
wait_until="domcontentloaded",
)
def test_profile_info_section(alice):
@@ -102,7 +108,9 @@ def test_profile_edit_bio(alice):
document.querySelector('textarea[name="bio"]').value = 'Test bio for testing';
document.querySelector('form[action="/profile/update"]').submit();
""")
page.wait_for_url(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
page.wait_for_url(
f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded"
)
page.locator("text=Test bio for testing").first.wait_for(state="visible")
@@ -113,7 +121,9 @@ def test_profile_edit_location(alice):
document.querySelector('input[name="location"]').value = 'Amsterdam';
document.querySelector('form[action="/profile/update"]').submit();
""")
page.wait_for_url(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
page.wait_for_url(
f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded"
)
page.locator("text=Amsterdam").first.wait_for(state="visible")
@@ -127,7 +137,9 @@ def test_profile_edit_all_fields(alice):
document.querySelector('input[name="website"]').value = 'https://alice.example.com';
document.querySelector('form[action="/profile/update"]').submit();
""")
page.wait_for_url(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
page.wait_for_url(
f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded"
)
page.locator("text=Full stack dev").first.wait_for(state="visible")
assert page.is_visible("text=London, UK")
assert page.is_visible("text=https://git.example.com/alice")
@@ -156,5 +168,8 @@ def test_profile_message_button_self(alice):
def test_profile_activity_tab(alice):
page, user = alice
page.goto(f"{BASE_URL}/profile/{user['username']}?tab=activity", wait_until="domcontentloaded")
page.goto(
f"{BASE_URL}/profile/{user['username']}?tab=activity",
wait_until="domcontentloaded",
)
page.wait_for_timeout(300)
+87 -25
View File
@@ -28,6 +28,7 @@ def _project():
# ---------------- in-process unit tests ----------------
def test_read_lines_range_and_total():
pid, u = _project()
pf.write_text_file(pid, u, "f.txt", "a\nb\nc\nd")
@@ -128,10 +129,16 @@ _counter = [0]
def _signup():
_counter[0] += 1
name = f"pl{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)
requests.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return name, get_table("users").find_one(username=name)["api_key"]
@@ -140,21 +147,34 @@ def _h(key):
def _create_project(key, title):
r = requests.post(f"{BASE_URL}/projects/create", headers=_h(key), data={
"title": title, "description": "lines test", "project_type": "software", "status": "In Development",
})
r = requests.post(
f"{BASE_URL}/projects/create",
headers=_h(key),
data={
"title": title,
"description": "lines test",
"project_type": "software",
"status": "In Development",
},
)
assert r.status_code == 200, r.text
return r.json()["data"]
def _write(key, slug, path, content):
r = requests.post(f"{BASE_URL}/projects/{slug}/files/write", headers=_h(key),
data={"path": path, "content": content}, allow_redirects=False)
r = requests.post(
f"{BASE_URL}/projects/{slug}/files/write",
headers=_h(key),
data={"path": path, "content": content},
allow_redirects=False,
)
assert r.status_code in (200, 302), r.text
def _raw(key, slug, path):
return requests.get(f"{BASE_URL}/projects/{slug}/files/raw", headers=_h(key), params={"path": path}).json()
return requests.get(
f"{BASE_URL}/projects/{slug}/files/raw", headers=_h(key), params={"path": path}
).json()
def test_http_read_lines(app_server):
@@ -162,7 +182,11 @@ def test_http_read_lines(app_server):
proj = _create_project(key, "HTTP Read Lines")
slug = proj["slug"] or proj["uid"]
_write(key, slug, "f.txt", "a\nb\nc\nd")
r = requests.get(f"{BASE_URL}/projects/{slug}/files/lines", headers=_h(key), params={"path": "f.txt", "start": 2, "end": 3})
r = requests.get(
f"{BASE_URL}/projects/{slug}/files/lines",
headers=_h(key),
params={"path": "f.txt", "start": 2, "end": 3},
)
assert r.status_code == 200
body = r.json()
assert body["lines"] == ["b", "c"]
@@ -174,17 +198,33 @@ def test_http_replace_insert_delete_append_roundtrip(app_server):
proj = _create_project(key, "HTTP Edit Roundtrip")
slug = proj["slug"] or proj["uid"]
_write(key, slug, "f.txt", "a\nb\nc\nd")
requests.post(f"{BASE_URL}/projects/{slug}/files/replace-lines", headers=_h(key),
data={"path": "f.txt", "start": 2, "end": 3, "content": "X\nY"}, allow_redirects=False)
requests.post(
f"{BASE_URL}/projects/{slug}/files/replace-lines",
headers=_h(key),
data={"path": "f.txt", "start": 2, "end": 3, "content": "X\nY"},
allow_redirects=False,
)
assert _raw(key, slug, "f.txt")["content"] == "a\nX\nY\nd"
requests.post(f"{BASE_URL}/projects/{slug}/files/insert-lines", headers=_h(key),
data={"path": "f.txt", "at": 1, "content": "TOP"}, allow_redirects=False)
requests.post(
f"{BASE_URL}/projects/{slug}/files/insert-lines",
headers=_h(key),
data={"path": "f.txt", "at": 1, "content": "TOP"},
allow_redirects=False,
)
assert _raw(key, slug, "f.txt")["content"] == "TOP\na\nX\nY\nd"
requests.post(f"{BASE_URL}/projects/{slug}/files/delete-lines", headers=_h(key),
data={"path": "f.txt", "start": 1, "end": 1}, allow_redirects=False)
requests.post(
f"{BASE_URL}/projects/{slug}/files/delete-lines",
headers=_h(key),
data={"path": "f.txt", "start": 1, "end": 1},
allow_redirects=False,
)
assert _raw(key, slug, "f.txt")["content"] == "a\nX\nY\nd"
requests.post(f"{BASE_URL}/projects/{slug}/files/append", headers=_h(key),
data={"path": "f.txt", "content": "END"}, allow_redirects=False)
requests.post(
f"{BASE_URL}/projects/{slug}/files/append",
headers=_h(key),
data={"path": "f.txt", "content": "END"},
allow_redirects=False,
)
assert _raw(key, slug, "f.txt")["content"] == "a\nX\nY\nd\nEND"
@@ -192,7 +232,11 @@ def test_http_lines_missing_file_404(app_server):
_, key = _signup()
proj = _create_project(key, "HTTP Lines 404")
slug = proj["slug"] or proj["uid"]
r = requests.get(f"{BASE_URL}/projects/{slug}/files/lines", headers=_h(key), params={"path": "nope.txt"})
r = requests.get(
f"{BASE_URL}/projects/{slug}/files/lines",
headers=_h(key),
params={"path": "nope.txt"},
)
assert r.status_code == 404
@@ -202,13 +246,18 @@ def test_http_replace_lines_non_owner_denied(app_server):
proj = _create_project(owner_key, "HTTP Owner Guard")
slug = proj["slug"] or proj["uid"]
_write(owner_key, slug, "f.txt", "a\nb")
r = requests.post(f"{BASE_URL}/projects/{slug}/files/replace-lines", headers=_h(other_key),
data={"path": "f.txt", "start": 1, "end": 1, "content": "x"}, allow_redirects=False)
r = requests.post(
f"{BASE_URL}/projects/{slug}/files/replace-lines",
headers=_h(other_key),
data={"path": "f.txt", "start": 1, "end": 1, "content": "x"},
allow_redirects=False,
)
assert r.status_code == 403
# ---------------- agent read-before-write guard ----------------
class _FakeClient:
authenticated = True
username = "u"
@@ -216,7 +265,9 @@ class _FakeClient:
def __init__(self):
self.calls = []
async def call(self, method, path, params=None, data=None, file_field=None, headers=None):
async def call(
self, method, path, params=None, data=None, file_field=None, headers=None
):
self.calls.append((method, path))
return {"ok": True}
@@ -224,6 +275,7 @@ class _FakeClient:
def _make_dispatcher():
import devplacepy.services.devii.actions.dispatcher as disp
from devplacepy.services.devii.actions.catalog import PLATFORM_CATALOG
d = disp.Dispatcher.__new__(disp.Dispatcher)
d._actions = PLATFORM_CATALOG.by_name()
d._client = _FakeClient()
@@ -242,6 +294,7 @@ def test_write_blocked_until_read(monkeypatch):
args = {"project_slug": "p", "path": "src/app.py", "content": "x"}
from devplacepy.services.devii.errors import ToolInputError
with pytest.raises(ToolInputError):
asyncio.run(d._run_http(write, args))
assert d._client.calls == []
@@ -258,6 +311,15 @@ def test_guard_path_normalization_matches(monkeypatch):
monkeypatch.setattr(disp, "get_store", lambda: None)
actions = d._actions
# read with a messy path, write with the clean path - normalization should unify them
asyncio.run(d._run_http(actions["project_read_file"], {"project_slug": "p", "path": "/src//app.py"}))
asyncio.run(d._run_http(actions["project_write_file"], {"project_slug": "p", "path": "src/app.py", "content": "x"}))
asyncio.run(
d._run_http(
actions["project_read_file"], {"project_slug": "p", "path": "/src//app.py"}
)
)
asyncio.run(
d._run_http(
actions["project_write_file"],
{"project_slug": "p", "path": "src/app.py", "content": "x"},
)
)
assert ("POST", "/projects/p/files/write") in d._client.calls
+146 -46
View File
@@ -13,10 +13,16 @@ def _signup():
_counter[0] += 1
name = f"pf{int(time.time() * 1000)}{_counter[0]}"
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)
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
key = get_table("users").find_one(username=name)["api_key"]
return name, key
@@ -26,42 +32,69 @@ def _h(key):
def _create_project(key, title):
r = requests.post(f"{BASE_URL}/projects/create", headers=_h(key), data={
"title": title, "description": "filesystem test",
"project_type": "software", "status": "In Development",
})
r = requests.post(
f"{BASE_URL}/projects/create",
headers=_h(key),
data={
"title": title,
"description": "filesystem test",
"project_type": "software",
"status": "In Development",
},
)
assert r.status_code == 200, r.text
return r.json()["data"]
def _write(key, slug, path, content):
return requests.post(f"{BASE_URL}/projects/{slug}/files/write", headers=_h(key),
data={"path": path, "content": content}, allow_redirects=False)
return requests.post(
f"{BASE_URL}/projects/{slug}/files/write",
headers=_h(key),
data={"path": path, "content": content},
allow_redirects=False,
)
def _mkdir(key, slug, path):
return requests.post(f"{BASE_URL}/projects/{slug}/files/mkdir", headers=_h(key),
data={"path": path}, allow_redirects=False)
return requests.post(
f"{BASE_URL}/projects/{slug}/files/mkdir",
headers=_h(key),
data={"path": path},
allow_redirects=False,
)
def _move(key, slug, from_path, to_path):
return requests.post(f"{BASE_URL}/projects/{slug}/files/move", headers=_h(key),
data={"from_path": from_path, "to_path": to_path}, allow_redirects=False)
return requests.post(
f"{BASE_URL}/projects/{slug}/files/move",
headers=_h(key),
data={"from_path": from_path, "to_path": to_path},
allow_redirects=False,
)
def _delete(key, slug, path):
return requests.post(f"{BASE_URL}/projects/{slug}/files/delete", headers=_h(key),
data={"path": path}, allow_redirects=False)
return requests.post(
f"{BASE_URL}/projects/{slug}/files/delete",
headers=_h(key),
data={"path": path},
allow_redirects=False,
)
def _list(slug, key=None):
return requests.get(f"{BASE_URL}/projects/{slug}/files",
headers=_h(key) if key else {"Accept": "application/json"})
return requests.get(
f"{BASE_URL}/projects/{slug}/files",
headers=_h(key) if key else {"Accept": "application/json"},
)
def _raw(slug, path, key=None):
return requests.get(f"{BASE_URL}/projects/{slug}/files/raw", params={"path": path},
headers=_h(key) if key else {"Accept": "application/json"})
return requests.get(
f"{BASE_URL}/projects/{slug}/files/raw",
params={"path": path},
headers=_h(key) if key else {"Accept": "application/json"},
)
def _paths(slug, key=None):
@@ -70,6 +103,7 @@ def _paths(slug, key=None):
# ---------- API: create / read ----------
def test_write_creates_file_and_parents(app_server):
_, key = _signup()
slug = _create_project(key, "FS Write")["slug"]
@@ -102,11 +136,16 @@ def test_list_empty_project(app_server):
_, key = _signup()
slug = _create_project(key, "FS Empty")["slug"]
r = _list(slug, key)
assert r.status_code == 200 and r.json()["files"] == [] and r.json()["is_owner"] is True
assert (
r.status_code == 200
and r.json()["files"] == []
and r.json()["is_owner"] is True
)
# ---------- API: directories ----------
def test_mkdir_recursive(app_server):
_, key = _signup()
slug = _create_project(key, "FS Mkdir")["slug"]
@@ -126,11 +165,16 @@ def test_mkdir_idempotent(app_server):
# ---------- API: upload (text + binary) ----------
def test_upload_text_is_editable(app_server):
_, key = _signup()
slug = _create_project(key, "FS UploadText")["slug"]
r = requests.post(f"{BASE_URL}/projects/{slug}/files/upload", headers=_h(key),
files={"file": ("util.py", b"x = 1\n")}, data={"path": "src"})
r = requests.post(
f"{BASE_URL}/projects/{slug}/files/upload",
headers=_h(key),
files={"file": ("util.py", b"x = 1\n")},
data={"path": "src"},
)
assert r.status_code == 200
node = _raw(slug, "src/util.py", key).json()
assert node["is_binary"] is False and node["content"] == "x = 1\n"
@@ -140,17 +184,24 @@ def test_upload_binary_is_served(app_server):
_, key = _signup()
slug = _create_project(key, "FS UploadBinary")["slug"]
blob = bytes(range(256))
r = requests.post(f"{BASE_URL}/projects/{slug}/files/upload", headers=_h(key),
files={"file": ("logo.bin", blob)}, data={"path": "assets"})
r = requests.post(
f"{BASE_URL}/projects/{slug}/files/upload",
headers=_h(key),
files={"file": ("logo.bin", blob)},
data={"path": "assets"},
)
assert r.status_code == 200
node = _raw(slug, "assets/logo.bin", key).json()
assert node["is_binary"] is True and node["url"].startswith("/static/uploads/project_files/")
assert node["is_binary"] is True and node["url"].startswith(
"/static/uploads/project_files/"
)
served = requests.get(BASE_URL + node["url"])
assert served.status_code == 200 and served.content == blob
# ---------- API: move / rename / delete ----------
def test_move_file(app_server):
_, key = _signup()
slug = _create_project(key, "FS MoveFile")["slug"]
@@ -189,6 +240,7 @@ def test_delete_directory_recursive(app_server):
# ---------- API: path safety ----------
def test_write_rejects_traversal(app_server):
_, key = _signup()
slug = _create_project(key, "FS Traversal")["slug"]
@@ -211,6 +263,7 @@ def test_raw_missing_file_404(app_server):
# ---------- API: permissions ----------
def test_public_read_without_auth(app_server):
_, key = _signup()
slug = _create_project(key, "FS Public")["slug"]
@@ -232,8 +285,11 @@ def test_non_owner_cannot_write(app_server):
def test_guest_write_blocked(app_server):
_, key = _signup()
slug = _create_project(key, "FS Guest")["slug"]
r = requests.post(f"{BASE_URL}/projects/{slug}/files/write",
data={"path": "x.py", "content": "1"}, allow_redirects=False)
r = requests.post(
f"{BASE_URL}/projects/{slug}/files/write",
data={"path": "x.py", "content": "1"},
allow_redirects=False,
)
assert r.status_code in (303, 401)
assert _paths(slug, key) == []
@@ -244,6 +300,7 @@ def test_unknown_project_404(app_server):
# ---------- API: cascade on project delete ----------
def test_project_delete_purges_files(app_server):
_, key = _signup()
project = _create_project(key, "FS Cascade")
@@ -251,27 +308,37 @@ def test_project_delete_purges_files(app_server):
_write(key, slug, "src/a.py", "1")
_write(key, slug, "src/b.py", "2")
assert get_table("project_files").count(project_uid=uid) == 3
r = requests.post(f"{BASE_URL}/projects/delete/{slug}", headers=_h(key), allow_redirects=False)
r = requests.post(
f"{BASE_URL}/projects/delete/{slug}", headers=_h(key), allow_redirects=False
)
assert r.status_code in (200, 302)
from devplacepy.database import refresh_snapshot
refresh_snapshot()
assert get_table("project_files").count(project_uid=uid) == 0
# ---------- Devii catalog + docs wiring (unit) ----------
def test_devii_actions_registered():
from devplacepy.services.devii.actions.catalog import ACTIONS
names = {a.name for a in ACTIONS}
assert {
"project_list_files", "project_read_file", "project_write_file",
"project_upload_file", "project_make_dir", "project_move_file",
"project_list_files",
"project_read_file",
"project_write_file",
"project_upload_file",
"project_make_dir",
"project_move_file",
"project_delete_file",
} <= names
def test_docs_group_present():
from devplacepy import docs_api
group = next((g for g in docs_api.API_GROUPS if g["slug"] == "project-files"), None)
assert group is not None
ids = {e["id"] for e in group["endpoints"]}
@@ -280,6 +347,7 @@ def test_docs_group_present():
# ---------- Web UI (Playwright) ----------
def _make_project_ui(page, title):
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.locator("#create-project-btn").click()
@@ -324,7 +392,9 @@ def test_edit_and_save_via_ui(alice):
page.goto(proj_url + "/files", wait_until="domcontentloaded")
_new_file(page, "main.py")
page.locator(".pf-node-row:has-text('main.py')").click()
page.wait_for_function("window.app && window.app.projectFiles && window.app.projectFiles.currentPath === 'main.py'")
page.wait_for_function(
"window.app && window.app.projectFiles && window.app.projectFiles.currentPath === 'main.py'"
)
page.wait_for_timeout(300)
page.evaluate("window.app.projectFiles.editor.setValue('print(42)')")
page.click("#pf-save")
@@ -347,9 +417,15 @@ def test_upload_via_ui(alice):
page.goto(page.url + "/files", wait_until="domcontentloaded")
with page.expect_file_chooser() as fc:
page.click("#pf-upload")
fc.value.set_files([{
"name": "data.json", "mimeType": "application/json", "buffer": b'{"a":1}',
}])
fc.value.set_files(
[
{
"name": "data.json",
"mimeType": "application/json",
"buffer": b'{"a":1}',
}
]
)
expect(page.locator(".pf-node-row:has-text('data.json')")).to_be_visible()
@@ -380,6 +456,7 @@ def test_non_owner_readonly_ui(alice, bob):
# ---------- Web UI: adding into a selected directory ----------
def _open_files(page, title):
proj_url = _make_project_ui(page, title)
slug = proj_url.rstrip("/").split("/")[-1]
@@ -444,9 +521,15 @@ def test_upload_into_selected_directory(alice):
page.locator(".pf-node-row:has-text('assets')").click()
with page.expect_file_chooser() as fc:
page.click("#pf-upload")
fc.value.set_files([{
"name": "config.json", "mimeType": "application/json", "buffer": b'{"k":1}',
}])
fc.value.set_files(
[
{
"name": "config.json",
"mimeType": "application/json",
"buffer": b'{"k":1}',
}
]
)
page.wait_for_timeout(400)
assert _raw(slug, "assets/config.json").status_code == 200
@@ -493,6 +576,7 @@ def test_add_file_at_root_when_nothing_selected(alice):
# ---------- Web UI: context menu, multi-select, drag-and-drop, long-press ----------
def _row(page, name):
return page.locator(f".pf-node-row:has-text('{name}')").first
@@ -557,7 +641,9 @@ def test_context_menu_new_file_in_directory(alice):
slug = _open_files(page, "UI CtxNewInDir")
_new_folder(page, "src")
_row(page, "src").click(button="right")
page.locator(".context-menu.visible .context-menu-item:has-text('New file here')").click()
page.locator(
".context-menu.visible .context-menu-item:has-text('New file here')"
).click()
_dialog_fill(page, "inside.py")
page.wait_for_timeout(300)
assert _raw(slug, "src/inside.py").status_code == 200
@@ -573,7 +659,9 @@ def test_multi_select_ctrl_and_delete(alice):
_row(page, "c.py").click(modifiers=["Control"])
expect(page.locator(".pf-node-row.pf-selected")).to_have_count(2)
_row(page, "c.py").click(button="right")
page.locator(".context-menu.visible .context-menu-item:has-text('Delete 2 items')").click()
page.locator(
".context-menu.visible .context-menu-item:has-text('Delete 2 items')"
).click()
_dialog_confirm(page)
page.wait_for_timeout(400)
assert _raw(slug, "a.py").status_code == 404
@@ -651,11 +739,14 @@ def test_long_press_opens_context_menu(mobile_page):
_write(key, slug, "touch.py", "x = 1")
page.goto(f"{BASE_URL}/projects/{slug}/files", wait_until="domcontentloaded")
page.wait_for_selector(".pf-node-row:has-text('touch.py')")
page.eval_on_selector(".pf-node-row:has-text('touch.py')", """el => {
page.eval_on_selector(
".pf-node-row:has-text('touch.py')",
"""el => {
const r = el.getBoundingClientRect();
const t = new Touch({identifier: 1, target: el, clientX: r.left + 10, clientY: r.top + 10});
el.dispatchEvent(new TouchEvent('touchstart', {bubbles: true, cancelable: true, touches: [t], targetTouches: [t], changedTouches: [t]}));
}""")
}""",
)
page.wait_for_timeout(700)
expect(page.locator(".context-menu.visible")).to_be_visible()
expect(page.locator(".context-menu.visible")).to_contain_text("Delete")
@@ -663,6 +754,7 @@ def test_long_press_opens_context_menu(mobile_page):
# ---------- Web UI: slick editor / auto-open (no wasted empty-state) ----------
def _alice_key():
return get_table("users").find_one(username="alice_test")["api_key"]
@@ -672,7 +764,9 @@ def test_auto_open_file_on_load(alice):
key = _alice_key()
project = _create_project(key, "AutoOpen")
_write(key, project["slug"], "main.py", "print('hi')")
page.goto(f"{BASE_URL}/projects/{project['slug']}/files", wait_until="domcontentloaded")
page.goto(
f"{BASE_URL}/projects/{project['slug']}/files", wait_until="domcontentloaded"
)
expect(page.locator("#pf-editor-wrap")).to_be_visible()
expect(page.locator("#pf-empty")).to_be_hidden()
@@ -683,8 +777,12 @@ def test_readme_preferred_on_load(alice):
project = _create_project(key, "ReadmePref")
_write(key, project["slug"], "main.py", "x = 1")
_write(key, project["slug"], "README.md", "# Hello Readme")
page.goto(f"{BASE_URL}/projects/{project['slug']}/files", wait_until="domcontentloaded")
page.wait_for_function("window.app && window.app.projectFiles && window.app.projectFiles.editor")
page.goto(
f"{BASE_URL}/projects/{project['slug']}/files", wait_until="domcontentloaded"
)
page.wait_for_function(
"window.app && window.app.projectFiles && window.app.projectFiles.editor"
)
page.wait_for_timeout(300)
content = page.evaluate("window.app.projectFiles.editor.getValue()")
assert "Hello Readme" in content
@@ -703,7 +801,9 @@ def test_folder_click_keeps_editor(alice):
key = _alice_key()
project = _create_project(key, "FolderKeepsEditor")
_write(key, project["slug"], "src/a.py", "x = 1")
page.goto(f"{BASE_URL}/projects/{project['slug']}/files", wait_until="domcontentloaded")
page.goto(
f"{BASE_URL}/projects/{project['slug']}/files", wait_until="domcontentloaded"
)
expect(page.locator("#pf-editor-wrap")).to_be_visible()
_row(page, "src").click()
expect(page.locator("#pf-editor-wrap")).to_be_visible()
+133 -37
View File
@@ -8,7 +8,10 @@ from devplacepy.database import get_table
from devplacepy.utils import clear_user_cache
from devplacepy import project_files
from devplacepy.project_files import ProjectFileError
from devplacepy.services.devii.actions.dispatcher import confirmation_error, _is_confirmed
from devplacepy.services.devii.actions.dispatcher import (
confirmation_error,
_is_confirmed,
)
_counter = [0]
@@ -17,10 +20,16 @@ def _signup():
_counter[0] += 1
name = f"pv{int(time.time() * 1000)}{_counter[0]}"
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)
session.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)
return name, row["uid"], row["api_key"]
@@ -40,8 +49,12 @@ def _h(key=None):
def _create_project(key, title, is_private=False):
data = {"title": title, "description": "visibility test",
"project_type": "software", "status": "In Development"}
data = {
"title": title,
"description": "visibility test",
"project_type": "software",
"status": "In Development",
}
if is_private:
data["is_private"] = "on"
r = requests.post(f"{BASE_URL}/projects/create", headers=_h(key), data=data)
@@ -54,18 +67,30 @@ def _project_uid(slug):
def _write(key, slug, path, content):
return requests.post(f"{BASE_URL}/projects/{slug}/files/write", headers=_h(key),
data={"path": path, "content": content}, allow_redirects=False)
return requests.post(
f"{BASE_URL}/projects/{slug}/files/write",
headers=_h(key),
data={"path": path, "content": content},
allow_redirects=False,
)
def _set_private(key, slug, value):
return requests.post(f"{BASE_URL}/projects/{slug}/private", headers=_h(key),
data={"value": 1 if value else 0}, allow_redirects=False)
return requests.post(
f"{BASE_URL}/projects/{slug}/private",
headers=_h(key),
data={"value": 1 if value else 0},
allow_redirects=False,
)
def _set_readonly(key, slug, value):
return requests.post(f"{BASE_URL}/projects/{slug}/readonly", headers=_h(key),
data={"value": 1 if value else 0}, allow_redirects=False)
return requests.post(
f"{BASE_URL}/projects/{slug}/readonly",
headers=_h(key),
data={"value": 1 if value else 0},
allow_redirects=False,
)
def _list_slugs(key=None, user_uid=None):
@@ -76,6 +101,7 @@ def _list_slugs(key=None, user_uid=None):
# ---------- privacy: listing ----------
def test_private_project_hidden_from_guest_listing(app_server):
_, owner_uid, key = _signup()
slug = _create_project(key, "Private Listing", is_private=True)["slug"]
@@ -99,32 +125,51 @@ def test_private_project_visible_to_admin(app_server):
# ---------- privacy: detail + files ----------
def test_private_detail_404_for_guest_200_for_owner(app_server):
_, _, key = _signup()
slug = _create_project(key, "Private Detail", is_private=True)["slug"]
assert requests.get(f"{BASE_URL}/projects/{slug}", headers=_h()).status_code == 404
assert requests.get(f"{BASE_URL}/projects/{slug}", headers=_h(key)).status_code == 200
assert (
requests.get(f"{BASE_URL}/projects/{slug}", headers=_h(key)).status_code == 200
)
def test_private_files_hidden_from_guest(app_server):
_, _, key = _signup()
slug = _create_project(key, "Private Files", is_private=True)["slug"]
_write(key, slug, "secret.txt", "classified")
assert requests.get(f"{BASE_URL}/projects/{slug}/files", headers=_h()).status_code == 404
assert requests.get(f"{BASE_URL}/projects/{slug}/files/raw", params={"path": "secret.txt"},
headers=_h()).status_code == 404
assert requests.get(f"{BASE_URL}/projects/{slug}/files", headers=_h(key)).status_code == 200
assert (
requests.get(f"{BASE_URL}/projects/{slug}/files", headers=_h()).status_code
== 404
)
assert (
requests.get(
f"{BASE_URL}/projects/{slug}/files/raw",
params={"path": "secret.txt"},
headers=_h(),
).status_code
== 404
)
assert (
requests.get(f"{BASE_URL}/projects/{slug}/files", headers=_h(key)).status_code
== 200
)
def test_private_detail_visible_to_admin(app_server):
_, _, owner_key = _signup()
_, _, admin_key = _make_admin()
slug = _create_project(owner_key, "Private AdminDetail", is_private=True)["slug"]
assert requests.get(f"{BASE_URL}/projects/{slug}", headers=_h(admin_key)).status_code == 200
assert (
requests.get(f"{BASE_URL}/projects/{slug}", headers=_h(admin_key)).status_code
== 200
)
# ---------- privacy: sitemap ----------
def test_private_project_excluded_from_sitemap(app_server):
_, _, key = _signup()
public_slug = _create_project(key, "Sitemap Public")["slug"]
@@ -136,6 +181,7 @@ def test_private_project_excluded_from_sitemap(app_server):
# ---------- privacy: toggle ----------
def test_toggle_private_then_public(app_server):
_, owner_uid, key = _signup()
slug = _create_project(key, "Toggle Privacy")["slug"]
@@ -156,6 +202,7 @@ def test_non_owner_cannot_toggle_flags(app_server):
# ---------- read-only: HTTP enforcement ----------
def test_readonly_blocks_every_mutation(app_server):
_, _, key = _signup()
slug = _create_project(key, "Readonly Block")["slug"]
@@ -164,20 +211,60 @@ def test_readonly_blocks_every_mutation(app_server):
assert _write(key, slug, "main.py", "print(2)\n").status_code == 400
assert _write(key, slug, "new.py", "print(3)\n").status_code == 400
assert requests.post(f"{BASE_URL}/projects/{slug}/files/mkdir", headers=_h(key),
data={"path": "docs"}, allow_redirects=False).status_code == 400
assert requests.post(f"{BASE_URL}/projects/{slug}/files/append", headers=_h(key),
data={"path": "main.py", "content": "x"}, allow_redirects=False).status_code == 400
assert requests.post(f"{BASE_URL}/projects/{slug}/files/replace-lines", headers=_h(key),
data={"path": "main.py", "start": 1, "end": 1, "content": "z"},
allow_redirects=False).status_code == 400
assert requests.post(f"{BASE_URL}/projects/{slug}/files/move", headers=_h(key),
data={"from_path": "main.py", "to_path": "renamed.py"},
allow_redirects=False).status_code == 400
assert requests.post(f"{BASE_URL}/projects/{slug}/files/delete", headers=_h(key),
data={"path": "main.py"}, allow_redirects=False).status_code == 400
assert requests.post(f"{BASE_URL}/projects/{slug}/files/upload", headers=_h(key),
files={"file": ("u.py", b"x=1\n")}, data={"path": ""}).status_code == 400
assert (
requests.post(
f"{BASE_URL}/projects/{slug}/files/mkdir",
headers=_h(key),
data={"path": "docs"},
allow_redirects=False,
).status_code
== 400
)
assert (
requests.post(
f"{BASE_URL}/projects/{slug}/files/append",
headers=_h(key),
data={"path": "main.py", "content": "x"},
allow_redirects=False,
).status_code
== 400
)
assert (
requests.post(
f"{BASE_URL}/projects/{slug}/files/replace-lines",
headers=_h(key),
data={"path": "main.py", "start": 1, "end": 1, "content": "z"},
allow_redirects=False,
).status_code
== 400
)
assert (
requests.post(
f"{BASE_URL}/projects/{slug}/files/move",
headers=_h(key),
data={"from_path": "main.py", "to_path": "renamed.py"},
allow_redirects=False,
).status_code
== 400
)
assert (
requests.post(
f"{BASE_URL}/projects/{slug}/files/delete",
headers=_h(key),
data={"path": "main.py"},
allow_redirects=False,
).status_code
== 400
)
assert (
requests.post(
f"{BASE_URL}/projects/{slug}/files/upload",
headers=_h(key),
files={"file": ("u.py", b"x=1\n")},
data={"path": ""},
).status_code
== 400
)
def test_readonly_unchanged_content(app_server):
@@ -186,8 +273,11 @@ def test_readonly_unchanged_content(app_server):
_write(key, slug, "a.txt", "original")
_set_readonly(key, slug, True)
_write(key, slug, "a.txt", "tampered")
body = requests.get(f"{BASE_URL}/projects/{slug}/files/raw", params={"path": "a.txt"},
headers=_h(key)).json()
body = requests.get(
f"{BASE_URL}/projects/{slug}/files/raw",
params={"path": "a.txt"},
headers=_h(key),
).json()
assert body["content"] == "original"
@@ -207,13 +297,18 @@ def test_delete_project_works_when_readonly(app_server):
slug = project["slug"]
_write(key, slug, "a.txt", "one")
_set_readonly(key, slug, True)
r = requests.post(f"{BASE_URL}/projects/delete/{slug}", headers=_h(key), allow_redirects=False)
r = requests.post(
f"{BASE_URL}/projects/delete/{slug}", headers=_h(key), allow_redirects=False
)
assert r.status_code == 200 and r.json()["ok"] is True
assert requests.get(f"{BASE_URL}/projects/{slug}", headers=_h(key)).status_code == 404
assert (
requests.get(f"{BASE_URL}/projects/{slug}", headers=_h(key)).status_code == 404
)
# ---------- read-only: service layer enforcement ----------
def test_readonly_guards_service_layer(app_server):
_, owner_uid, key = _signup()
slug = _create_project(key, "Readonly Service")["slug"]
@@ -242,6 +337,7 @@ def test_readonly_blocks_import_from_dir(app_server, tmp_path):
# ---------- devii confirmation gate ----------
def test_readonly_action_requires_confirmation():
assert confirmation_error("project_set_readonly", {}) is not None
assert confirmation_error("project_set_readonly", {"confirm": "false"}) is not None
+24 -11
View File
@@ -11,22 +11,35 @@ from devplacepy.utils import make_combined_slug
def _seed_projects(count):
owner = str(uuid4())
get_table("users").insert({
"uid": owner, "username": f"pag_{owner[:8]}", "email": f"{owner[:8]}@test.devplace",
"password_hash": "x", "role": "Member", "is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
})
get_table("users").insert(
{
"uid": owner,
"username": f"pag_{owner[:8]}",
"email": f"{owner[:8]}@test.devplace",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
projects = get_table("projects")
for i in range(count):
uid = str(uuid4())
title = f"Pag Project {i}"
projects.insert({
"uid": uid, "user_uid": owner, "slug": make_combined_slug(title, uid),
"title": title, "description": "paginated", "project_type": "software",
"status": "In Development", "stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
})
projects.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(title, uid),
"title": title,
"description": "paginated",
"project_type": "software",
"status": "In Development",
"stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
}
)
return owner
+34 -15
View File
@@ -8,12 +8,16 @@ from tests.conftest import BASE_URL
def _session():
s = requests.Session()
name = f"push_{uuid.uuid4().hex[:10]}"
s.post(f"{BASE_URL}/auth/signup", data={
"username": name,
"email": f"{name}@test.dev",
"password": "secret123",
"confirm_password": "secret123",
}, allow_redirects=True)
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@test.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s
@@ -24,19 +28,26 @@ def test_public_key_endpoint(app_server):
def test_register_requires_auth(app_server):
r = requests.post(f"{BASE_URL}/push.json", json={
"endpoint": "https://push.example.com/anon",
"keys": {"p256dh": "key", "auth": "auth"},
}, allow_redirects=False)
r = requests.post(
f"{BASE_URL}/push.json",
json={
"endpoint": "https://push.example.com/anon",
"keys": {"p256dh": "key", "auth": "auth"},
},
allow_redirects=False,
)
assert r.status_code == 401
def test_register_success(app_server):
s = _session()
r = s.post(f"{BASE_URL}/push.json", json={
"endpoint": "https://push.example.com/sub-1",
"keys": {"p256dh": "p256dh_fake", "auth": "auth_fake"},
})
r = s.post(
f"{BASE_URL}/push.json",
json={
"endpoint": "https://push.example.com/sub-1",
"keys": {"p256dh": "p256dh_fake", "auth": "auth_fake"},
},
)
assert r.status_code == 200, r.text
assert r.json().get("registered") is True
@@ -49,7 +60,11 @@ def test_register_missing_keys_rejected(app_server):
def test_register_invalid_json_rejected(app_server):
s = _session()
r = s.post(f"{BASE_URL}/push.json", data="not-json", headers={"Content-Type": "application/json"})
r = s.post(
f"{BASE_URL}/push.json",
data="not-json",
headers={"Content-Type": "application/json"},
)
assert r.status_code == 400
@@ -67,6 +82,7 @@ def test_manifest_served(app_server):
def test_browser_base64_is_url_safe_unpadded():
import base64
from devplacepy import push
encoded = push.browser_base64(b"\xff\xfe\x00 hello")
assert "=" not in encoded
assert "+" not in encoded and "/" not in encoded
@@ -75,6 +91,7 @@ def test_browser_base64_is_url_safe_unpadded():
def test_hkdf_returns_requested_length():
from devplacepy import push
derived = push.hkdf(b"input-key-material", b"salt", b"info", 16)
assert isinstance(derived, bytes)
assert len(derived) == 16
@@ -82,10 +99,12 @@ def test_hkdf_returns_requested_length():
def test_public_key_standard_b64_non_empty():
from devplacepy import push
assert push.public_key_standard_b64()
def test_create_notification_authorization_is_jwt():
from devplacepy import push
token = push.create_notification_authorization("https://push.example.com/endpoint")
assert token.count(".") == 2
+12 -3
View File
@@ -4,7 +4,11 @@ from starlette.testclient import TestClient
def _patch_limits(monkeypatch, limit, window=60):
def fake_get_int_setting(key, default):
return {"rate_limit_per_minute": limit, "rate_limit_window_seconds": window}.get(key, default)
return {
"rate_limit_per_minute": limit,
"rate_limit_window_seconds": window,
}.get(key, default)
monkeypatch.setattr(m, "get_int_setting", fake_get_int_setting)
m._rate_limit_store.clear()
@@ -12,7 +16,9 @@ def _patch_limits(monkeypatch, limit, window=60):
def test_rate_limit_blocks_excess(monkeypatch):
_patch_limits(monkeypatch, 3)
client = TestClient(m.app)
codes = [client.post("/", headers={"X-Real-IP": "9.9.9.9"}).status_code for _ in range(6)]
codes = [
client.post("/", headers={"X-Real-IP": "9.9.9.9"}).status_code for _ in range(6)
]
assert 429 in codes, codes
assert codes[-1] == 429
@@ -31,7 +37,10 @@ def test_rate_limit_is_per_ip(monkeypatch):
def test_get_requests_not_rate_limited(monkeypatch):
_patch_limits(monkeypatch, 2)
client = TestClient(m.app)
codes = [client.get("/robots.txt", headers={"X-Real-IP": "3.3.3.3"}).status_code for _ in range(5)]
codes = [
client.get("/robots.txt", headers={"X-Real-IP": "3.3.3.3"}).status_code
for _ in range(5)
]
assert all(c == 200 for c in codes), codes
+86 -30
View File
@@ -21,10 +21,16 @@ def _session():
_counter[0] += 1
name = f"rxn{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": "secret123", "confirm_password": "secret123",
}, allow_redirects=True)
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
@@ -54,29 +60,46 @@ def _live_post_uid(slug):
def _make_post(owner_uid):
uid = generate_uid()
get_table("posts").insert({
"uid": uid, "user_uid": owner_uid, "slug": f"{uid[:8]}-reaction-post",
"title": None, "content": "reaction target content", "topic": "random",
"project_uid": None, "image": None, "stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
})
get_table("posts").insert(
{
"uid": uid,
"user_uid": owner_uid,
"slug": f"{uid[:8]}-reaction-post",
"title": None,
"content": "reaction target content",
"topic": "random",
"project_uid": None,
"image": None,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def _make_comment(post_uid, owner_uid):
uid = generate_uid()
get_table("comments").insert({
"uid": uid, "target_type": "post", "target_uid": post_uid, "post_uid": post_uid,
"user_uid": owner_uid, "content": "reaction target comment", "parent_uid": None,
"created_at": datetime.now(timezone.utc).isoformat(),
})
get_table("comments").insert(
{
"uid": uid,
"target_type": "post",
"target_uid": post_uid,
"post_uid": post_uid,
"user_uid": owner_uid,
"content": "reaction target comment",
"parent_uid": None,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def test_add_reaction_returns_counts(app_server):
s, name = _session()
post_uid = _make_post(_uid(name))
r = s.post(f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": ROCKET}, headers=AJAX)
r = s.post(
f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": ROCKET}, headers=AJAX
)
payload = r.json()
assert payload["counts"][ROCKET] == 1
assert ROCKET in payload["mine"]
@@ -86,8 +109,12 @@ def test_add_reaction_returns_counts(app_server):
def test_same_emoji_toggles_off(app_server):
s, name = _session()
post_uid = _make_post(_uid(name))
s.post(f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": ROCKET}, headers=AJAX)
r = s.post(f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": ROCKET}, headers=AJAX)
s.post(
f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": ROCKET}, headers=AJAX
)
r = s.post(
f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": ROCKET}, headers=AJAX
)
payload = r.json()
assert payload["counts"].get(ROCKET, 0) == 0
assert payload["mine"] == []
@@ -97,8 +124,12 @@ def test_same_emoji_toggles_off(app_server):
def test_two_distinct_emoji_both_counted(app_server):
s, name = _session()
post_uid = _make_post(_uid(name))
s.post(f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": ROCKET}, headers=AJAX)
r = s.post(f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": HEART}, headers=AJAX)
s.post(
f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": ROCKET}, headers=AJAX
)
r = s.post(
f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": HEART}, headers=AJAX
)
payload = r.json()
assert payload["counts"][ROCKET] == 1
assert payload["counts"][HEART] == 1
@@ -108,7 +139,12 @@ def test_two_distinct_emoji_both_counted(app_server):
def test_emoji_outside_palette_rejected(app_server):
s, name = _session()
post_uid = _make_post(_uid(name))
r = s.post(f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": "notanemoji"}, headers=AJAX, allow_redirects=False)
r = s.post(
f"{BASE_URL}/reactions/post/{post_uid}",
data={"emoji": "notanemoji"},
headers=AJAX,
allow_redirects=False,
)
assert r.status_code == 303
assert get_table("reactions").count(target_type="post", target_uid=post_uid) == 0
@@ -116,7 +152,9 @@ def test_emoji_outside_palette_rejected(app_server):
def test_invalid_target_type_returns_400(app_server):
s, name = _session()
post_uid = _make_post(_uid(name))
r = s.post(f"{BASE_URL}/reactions/news/{post_uid}", data={"emoji": ROCKET}, headers=AJAX)
r = s.post(
f"{BASE_URL}/reactions/news/{post_uid}", data={"emoji": ROCKET}, headers=AJAX
)
assert r.status_code == 400
@@ -124,7 +162,12 @@ def test_reaction_requires_login(app_server):
owner_s, owner_name = _session()
post_uid = _make_post(_uid(owner_name))
anon = requests.Session()
r = anon.post(f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": ROCKET}, headers=AJAX, allow_redirects=False)
r = anon.post(
f"{BASE_URL}/reactions/post/{post_uid}",
data={"emoji": ROCKET},
headers=AJAX,
allow_redirects=False,
)
assert r.status_code == 303
assert get_table("reactions").count(target_type="post", target_uid=post_uid) == 0
@@ -134,21 +177,34 @@ def test_react_on_comment_target(app_server):
owner_uid = _uid(name)
post_uid = _make_post(owner_uid)
comment_uid = _make_comment(post_uid, owner_uid)
r = s.post(f"{BASE_URL}/reactions/comment/{comment_uid}", data={"emoji": HEART}, headers=AJAX)
r = s.post(
f"{BASE_URL}/reactions/comment/{comment_uid}",
data={"emoji": HEART},
headers=AJAX,
)
assert r.json()["counts"][HEART] == 1
assert get_table("reactions").count(target_type="comment", target_uid=comment_uid) == 1
assert (
get_table("reactions").count(target_type="comment", target_uid=comment_uid) == 1
)
def test_delete_post_cascades_reactions(app_server):
s, name = _session()
title = f"cascade-{int(time.time() * 1000)}"
r = s.post(f"{BASE_URL}/posts/create", data={
"content": "Post created via the server for the reaction cascade test.",
"title": title, "topic": "random",
}, allow_redirects=False)
r = s.post(
f"{BASE_URL}/posts/create",
data={
"content": "Post created via the server for the reaction cascade test.",
"title": title,
"topic": "random",
},
allow_redirects=False,
)
slug = r.headers["location"].split("/posts/")[-1]
post_uid = _live_post_uid(slug)
s.post(f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": ROCKET}, headers=AJAX)
s.post(
f"{BASE_URL}/reactions/post/{post_uid}", data={"emoji": ROCKET}, headers=AJAX
)
assert _live_reaction_count("post", post_uid) == 1
s.post(f"{BASE_URL}/posts/delete/{slug}", allow_redirects=False)
assert s.get(f"{BASE_URL}/posts/{slug}").status_code == 404
+34 -8
View File
@@ -17,10 +17,16 @@ def _author():
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)
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"])
@@ -28,12 +34,20 @@ def _author():
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"})
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
@@ -67,6 +81,7 @@ def test_guest_docs_hide_admin(page, app_server):
# ---------- 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")
@@ -95,13 +110,19 @@ def test_member_can_act(bob):
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
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")
@@ -118,6 +139,11 @@ def test_admin_sees_role_badges(alice):
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
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 -54
View File
@@ -98,10 +98,15 @@ def test_post_page_has_structured_data(page, app_server):
page.click("button:has-text('Create account')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
page.locator(".feed-fab").click()
page.fill("#post-content", "This is a post for SEO testing with structured data schema validation")
page.fill(
"#post-content",
"This is a post for SEO testing with structured data schema validation",
)
page.fill("#post-title", "SEO Post For Schema")
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", timeout=10000, wait_until="domcontentloaded")
page.wait_for_url(
f"{BASE_URL}/posts/*", timeout=10000, wait_until="domcontentloaded"
)
scripts = page.locator('script[type="application/ld+json"]')
count = scripts.count()
assert count >= 1
@@ -151,22 +156,25 @@ def _seed_news():
from datetime import datetime, timezone
from devplacepy.database import get_table
from devplacepy.utils import generate_uid, make_combined_slug
uid = generate_uid()
title = f"SEO Test News Article {uid.split('-')[-1]}"
slug = make_combined_slug(title, uid)
get_table("news").insert({
"uid": uid,
"slug": slug,
"title": title,
"description": "A seeded news article for SEO tests.",
"content": "Body content for the seeded article.",
"url": "https://example.com/article",
"source_name": "ExampleSource",
"status": "published",
"synced_at": datetime.now(timezone.utc).isoformat(),
"show_on_landing": 0,
"grade": 8,
})
get_table("news").insert(
{
"uid": uid,
"slug": slug,
"title": title,
"description": "A seeded news article for SEO tests.",
"content": "Body content for the seeded article.",
"url": "https://example.com/article",
"source_name": "ExampleSource",
"status": "published",
"synced_at": datetime.now(timezone.utc).isoformat(),
"show_on_landing": 0,
"grade": 8,
}
)
return slug, uid
@@ -213,23 +221,28 @@ def test_news_detail_has_newsarticle_schema(page, app_server):
scripts = page.locator('script[type="application/ld+json"]')
text = " ".join(scripts.nth(i).text_content() for i in range(scripts.count()))
assert "NewsArticle" in text
assert page.locator('meta[property="og:type"]').get_attribute("content") == "article"
assert (
page.locator('meta[property="og:type"]').get_attribute("content") == "article"
)
def _seed_owner():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
uid = str(uuid4())
get_table("users").insert({
"uid": uid,
"username": f"seo_{uid[:8]}",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
})
get_table("users").insert(
{
"uid": uid,
"username": f"seo_{uid[:8]}",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
@@ -238,15 +251,24 @@ def _seed_post(image=None):
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Post", uid)
get_table("posts").insert({
"uid": uid, "user_uid": owner, "slug": slug,
"title": "SEO Detail Post", "content": "Body text for the SEO detail post.",
"topic": "general", "project_uid": None, "image": image,
"stars": 0, "created_at": datetime.now(timezone.utc).isoformat(),
})
get_table("posts").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Post",
"content": "Body text for the SEO detail post.",
"topic": "general",
"project_uid": None,
"image": image,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
@@ -255,15 +277,23 @@ def _seed_gist():
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Gist", uid)
get_table("gists").insert({
"uid": uid, "user_uid": owner, "slug": slug,
"title": "SEO Detail Gist", "description": "Gist description for SEO tests.",
"source_code": "print('seo')", "language": "python",
"stars": 0, "created_at": datetime.now(timezone.utc).isoformat(),
})
get_table("gists").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Gist",
"description": "Gist description for SEO tests.",
"source_code": "print('seo')",
"language": "python",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
@@ -272,24 +302,36 @@ def _seed_project():
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Project", uid)
get_table("projects").insert({
"uid": uid, "user_uid": owner, "slug": slug,
"title": "SEO Detail Project", "description": "Project description for SEO tests.",
"project_type": "software", "platforms": "Linux", "status": "Released",
"stars": 0, "created_at": datetime.now(timezone.utc).isoformat(),
})
get_table("projects").insert(
{
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "SEO Detail Project",
"description": "Project description for SEO tests.",
"project_type": "software",
"platforms": "Linux",
"status": "Released",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid
def _seed_news_image(news_uid):
from devplacepy.database import get_table
get_table("news_images").insert({
"news_uid": news_uid,
"url": "https://example.com/seo-news-image.jpg",
})
get_table("news_images").insert(
{
"news_uid": news_uid,
"url": "https://example.com/seo-news-image.jpg",
}
)
def _seed_feed_posts(count):
@@ -297,18 +339,27 @@ def _seed_feed_posts(count):
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
topic = f"seopag{owner[:8]}"
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
posts = get_table("posts")
for i in range(count):
uid = str(uuid4())
posts.insert({
"uid": uid, "user_uid": owner, "slug": make_combined_slug(f"seo pag {i}", uid),
"title": None, "content": f"seo pag post {i}", "topic": topic, "project_uid": None,
"image": None, "stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
})
posts.insert(
{
"uid": uid,
"user_uid": owner,
"slug": make_combined_slug(f"seo pag {i}", uid),
"title": None,
"content": f"seo pag post {i}",
"topic": topic,
"project_uid": None,
"image": None,
"stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
}
)
return topic
@@ -336,7 +387,9 @@ def test_project_uid_redirects_to_canonical_slug(app_server):
slug, uid = _seed_project()
r = requests.get(f"{BASE_URL}/projects/{uid}", allow_redirects=False)
assert r.status_code == 301
assert r.headers["location"].endswith(f"/projects/{slug}"), r.headers.get("location")
assert r.headers["location"].endswith(f"/projects/{slug}"), r.headers.get(
"location"
)
def test_news_uid_redirects_to_canonical_slug(app_server):
+18 -4
View File
@@ -10,18 +10,32 @@ def test_truncate_no_overflow():
def test_software_application_url_is_per_project():
schema = seo.software_application_schema({"uid": "p1", "slug": "p1-foo", "title": "P"}, "https://x.test")
schema = seo.software_application_schema(
{"uid": "p1", "slug": "p1-foo", "title": "P"}, "https://x.test"
)
assert schema["url"] == "https://x.test/projects/p1-foo"
def test_schema_types():
assert seo.organization_schema("https://x.test")["@type"] == "Organization"
assert seo.news_article_schema({"uid": "n1", "title": "T", "synced_at": "2026-01-01"}, "https://x.test")["@type"] == "NewsArticle"
assert seo.software_source_code_schema({"uid": "g1", "title": "G", "language": "python"}, "https://x.test")["@type"] == "SoftwareSourceCode"
assert (
seo.news_article_schema(
{"uid": "n1", "title": "T", "synced_at": "2026-01-01"}, "https://x.test"
)["@type"]
== "NewsArticle"
)
assert (
seo.software_source_code_schema(
{"uid": "g1", "title": "G", "language": "python"}, "https://x.test"
)["@type"]
== "SoftwareSourceCode"
)
def test_news_article_publisher_is_organization():
schema = seo.news_article_schema({"uid": "n1", "title": "T", "synced_at": "2026-01-01"}, "https://x.test")
schema = seo.news_article_schema(
{"uid": "n1", "title": "T", "synced_at": "2026-01-01"}, "https://x.test"
)
assert schema["publisher"]["@type"] == "Organization"
+21 -6
View File
@@ -30,6 +30,7 @@ def test_services_data_json(page, seeded_db):
page.goto(f"{BASE_URL}/admin/services/data", wait_until="domcontentloaded")
text = page.inner_text("pre") if page.locator("pre").count() > 0 else page.content()
import json
data = json.loads(text)
assert "services" in data
assert isinstance(data["services"], list)
@@ -106,17 +107,23 @@ def test_services_config_validation(page, seeded_db):
_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"})
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"})
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"})
page.request.post(
f"{BASE_URL}/admin/services/news/config", form={"news_grade_threshold": "7"}
)
def test_services_unknown_returns_404(page, seeded_db):
@@ -148,11 +155,19 @@ def test_bots_config_float_validation(page, seeded_db):
_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"})
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"})
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"})
page.request.post(
f"{BASE_URL}/admin/services/bots/config",
form={"bot_input_cost_per_1m": "0.27"},
)
+64 -25
View File
@@ -4,7 +4,13 @@ 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, get_activity_months
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]
@@ -14,32 +20,54 @@ def _make_user():
_counter[0] += 1
uid = generate_uid()
name = f"stk{int(time.time() * 1000)}{_counter[0]}"
get_table("users").insert({
"uid": uid, "username": name, "email": f"{name}@t.dev",
"role": "Member", "is_active": True, "xp": 0, "level": 1,
"created_at": datetime.now(timezone.utc).isoformat(),
})
get_table("users").insert(
{
"uid": uid,
"username": name,
"email": f"{name}@t.dev",
"role": "Member",
"is_active": True,
"xp": 0,
"level": 1,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
def _insert_post(user_uid, dt):
uid = generate_uid()
get_table("posts").insert({
"uid": uid, "user_uid": user_uid, "slug": f"{uid[:8]}-streak",
"title": None, "content": "streak activity", "topic": "random",
"project_uid": None, "image": None, "stars": 0,
"created_at": dt.isoformat(),
})
get_table("posts").insert(
{
"uid": uid,
"user_uid": user_uid,
"slug": f"{uid[:8]}-streak",
"title": None,
"content": "streak activity",
"topic": "random",
"project_uid": None,
"image": None,
"stars": 0,
"created_at": dt.isoformat(),
}
)
return uid
def _insert_comment(user_uid, post_uid, dt):
uid = generate_uid()
get_table("comments").insert({
"uid": uid, "target_type": "post", "target_uid": post_uid, "post_uid": post_uid,
"user_uid": user_uid, "content": "streak comment", "parent_uid": None,
"created_at": dt.isoformat(),
})
get_table("comments").insert(
{
"uid": uid,
"target_type": "post",
"target_uid": post_uid,
"post_uid": post_uid,
"user_uid": user_uid,
"content": "streak comment",
"parent_uid": None,
"created_at": dt.isoformat(),
}
)
return uid
@@ -115,14 +143,25 @@ def test_profile_renders_heatmap_and_streak(app_server):
_counter[0] += 1
name = f"stkp{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": "secret123", "confirm_password": "secret123",
}, allow_redirects=True)
s.post(f"{BASE_URL}/posts/create", data={
"content": "A post created today for the streak heatmap.",
"title": "Streak heatmap post", "topic": "devlog",
}, allow_redirects=True)
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
s.post(
f"{BASE_URL}/posts/create",
data={
"content": "A post created today for the streak heatmap.",
"title": "Streak heatmap post",
"topic": "devlog",
},
allow_redirects=True,
)
html = s.get(f"{BASE_URL}/profile/{name}").text
assert "heatmap-grid" in html
assert "1 day streak" in html
+3 -1
View File
@@ -15,7 +15,9 @@ def test_data_confirm_opens_custom_dialog(alice):
_open_project_with_delete(page, "Confirm Dialog Open")
page.locator("button[data-confirm]").first.click()
expect(page.locator(".dialog-overlay.visible")).to_be_visible()
expect(page.locator(".dialog-overlay.visible .dialog-message")).to_contain_text("Delete")
expect(page.locator(".dialog-overlay.visible .dialog-message")).to_contain_text(
"Delete"
)
def test_data_confirm_accept_proceeds(alice):
+146 -57
View File
@@ -9,12 +9,16 @@ from tests.conftest import BASE_URL
def _user(prefix="up"):
s = requests.Session()
name = f"{prefix}_{uuid.uuid4().hex[:10]}"
s.post(f"{BASE_URL}/auth/signup", data={
"username": name,
"email": f"{name}@test.dev",
"password": "secret123",
"confirm_password": "secret123",
}, allow_redirects=True)
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@test.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
@@ -30,40 +34,56 @@ def _png_bytes():
def _upload(s, name="a.png"):
r = s.post(f"{BASE_URL}/uploads/upload", files={"file": (name, _png_bytes(), "image/png")})
r = s.post(
f"{BASE_URL}/uploads/upload", files={"file": (name, _png_bytes(), "image/png")}
)
assert r.status_code == 201, r.text
return r.json()["uid"]
def test_upload_allowed_png(app_server):
s = _session()
r = s.post(f"{BASE_URL}/uploads/upload", files={"file": ("x.png", _png_bytes(), "image/png")})
r = s.post(
f"{BASE_URL}/uploads/upload",
files={"file": ("x.png", _png_bytes(), "image/png")},
)
assert r.status_code == 201, r.text
assert "url" in r.json()
def test_upload_rejects_svg(app_server):
s = _session()
r = s.post(f"{BASE_URL}/uploads/upload", files={"file": ("x.svg", b"<svg/>", "image/svg+xml")})
r = s.post(
f"{BASE_URL}/uploads/upload",
files={"file": ("x.svg", b"<svg/>", "image/svg+xml")},
)
assert r.status_code == 415
def test_upload_rejects_html(app_server):
s = _session()
r = s.post(f"{BASE_URL}/uploads/upload", files={"file": ("x.html", b"<html></html>", "text/html")})
r = s.post(
f"{BASE_URL}/uploads/upload",
files={"file": ("x.html", b"<html></html>", "text/html")},
)
assert r.status_code == 415
def test_upload_rejects_oversize(app_server):
s = _session()
big = b"\x89PNG\r\n" + b"\x00" * (11 * 1024 * 1024)
r = s.post(f"{BASE_URL}/uploads/upload", files={"file": ("big.png", big, "image/png")})
r = s.post(
f"{BASE_URL}/uploads/upload", files={"file": ("big.png", big, "image/png")}
)
assert r.status_code == 413
def test_image_served_inline(app_server):
s = _session()
r = s.post(f"{BASE_URL}/uploads/upload", files={"file": ("x.png", _png_bytes(), "image/png")})
r = s.post(
f"{BASE_URL}/uploads/upload",
files={"file": ("x.png", _png_bytes(), "image/png")},
)
url = r.json()["url"]
served = s.get(f"{BASE_URL}{url}")
assert served.status_code == 200
@@ -72,7 +92,10 @@ def test_image_served_inline(app_server):
def test_nonmedia_served_as_attachment(app_server):
s = _session()
r = s.post(f"{BASE_URL}/uploads/upload", files={"file": ("note.txt", b"hello world", "text/plain")})
r = s.post(
f"{BASE_URL}/uploads/upload",
files={"file": ("note.txt", b"hello world", "text/plain")},
)
url = r.json()["url"]
served = s.get(f"{BASE_URL}{url}")
assert served.status_code == 200
@@ -80,13 +103,20 @@ def test_nonmedia_served_as_attachment(app_server):
def test_upload_requires_login(app_server):
r = requests.post(f"{BASE_URL}/uploads/upload", files={"file": ("x.png", _png_bytes(), "image/png")}, allow_redirects=False)
r = requests.post(
f"{BASE_URL}/uploads/upload",
files={"file": ("x.png", _png_bytes(), "image/png")},
allow_redirects=False,
)
assert r.status_code == 401
def test_delete_own_allowed_other_user_forbidden(app_server):
alice = _session()
uid = alice.post(f"{BASE_URL}/uploads/upload", files={"file": ("x.png", _png_bytes(), "image/png")}).json()["uid"]
uid = alice.post(
f"{BASE_URL}/uploads/upload",
files={"file": ("x.png", _png_bytes(), "image/png")},
).json()["uid"]
bob = _session()
assert bob.delete(f"{BASE_URL}/uploads/delete/{uid}").status_code == 403
assert alice.delete(f"{BASE_URL}/uploads/delete/{uid}").status_code == 200
@@ -95,91 +125,150 @@ def test_delete_own_allowed_other_user_forbidden(app_server):
def test_post_links_multiple_attachments(app_server):
s = _session()
u1, u2 = _upload(s), _upload(s)
r = s.post(f"{BASE_URL}/posts/create", data={
"content": "Post body with two attachments here",
"title": "attach post", "topic": "random",
"attachment_uids": f"{u1},{u2}",
}, allow_redirects=True)
r = s.post(
f"{BASE_URL}/posts/create",
data={
"content": "Post body with two attachments here",
"title": "attach post",
"topic": "random",
"attachment_uids": f"{u1},{u2}",
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert "/posts/" in r.url, r.url
assert u1 in r.text and u2 in r.text, "both attachments must be linked and displayed on the post"
assert u1 in r.text and u2 in r.text, (
"both attachments must be linked and displayed on the post"
)
def test_comment_links_multiple_attachments(app_server):
s = _session()
post = s.post(f"{BASE_URL}/posts/create", data={
"content": "Host post for comment attachments", "title": "host", "topic": "random",
}, allow_redirects=True)
post = s.post(
f"{BASE_URL}/posts/create",
data={
"content": "Host post for comment attachments",
"title": "host",
"topic": "random",
},
allow_redirects=True,
)
target_uid = re.search(r'name="target_uid"\s+value="([^"]+)"', post.text).group(1)
u1, u2 = _upload(s), _upload(s)
r = s.post(f"{BASE_URL}/comments/create", data={
"content": "Comment with attachments",
"target_uid": target_uid, "target_type": "post",
"attachment_uids": f"{u1},{u2}",
}, allow_redirects=True)
r = s.post(
f"{BASE_URL}/comments/create",
data={
"content": "Comment with attachments",
"target_uid": target_uid,
"target_type": "post",
"attachment_uids": f"{u1},{u2}",
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert u1 in r.text and u2 in r.text, "both attachments must be linked and displayed on the comment"
assert u1 in r.text and u2 in r.text, (
"both attachments must be linked and displayed on the comment"
)
def test_project_links_multiple_attachments(app_server):
s = _session()
u1, u2 = _upload(s), _upload(s)
r = s.post(f"{BASE_URL}/projects/create", data={
"title": "Attach Project", "description": "Project with attachments",
"project_type": "software", "platforms": "linux", "status": "In Development",
"attachment_uids": f"{u1},{u2}",
}, allow_redirects=True)
r = s.post(
f"{BASE_URL}/projects/create",
data={
"title": "Attach Project",
"description": "Project with attachments",
"project_type": "software",
"platforms": "linux",
"status": "In Development",
"attachment_uids": f"{u1},{u2}",
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert "/projects/" in r.url, r.url
assert u1 in r.text and u2 in r.text, "both attachments must be linked and displayed on the project"
assert u1 in r.text and u2 in r.text, (
"both attachments must be linked and displayed on the project"
)
def test_gist_links_multiple_attachments(app_server):
s = _session()
u1, u2 = _upload(s), _upload(s)
r = s.post(f"{BASE_URL}/gists/create", data={
"title": "Attach Gist", "description": "Gist with attachments",
"source_code": "print('hi')", "language": "python",
"attachment_uids": f"{u1},{u2}",
}, allow_redirects=True)
r = s.post(
f"{BASE_URL}/gists/create",
data={
"title": "Attach Gist",
"description": "Gist with attachments",
"source_code": "print('hi')",
"language": "python",
"attachment_uids": f"{u1},{u2}",
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert "/gists/" in r.url, r.url
assert u1 in r.text and u2 in r.text, "both attachments must be linked and displayed on the gist"
assert u1 in r.text and u2 in r.text, (
"both attachments must be linked and displayed on the gist"
)
def test_bug_links_multiple_attachments(app_server):
s = _session()
u1, u2 = _upload(s), _upload(s)
r = s.post(f"{BASE_URL}/bugs/create", data={
"title": "Attach Bug", "description": "Bug report with attachments",
"attachment_uids": f"{u1},{u2}",
}, allow_redirects=True)
r = s.post(
f"{BASE_URL}/bugs/create",
data={
"title": "Attach Bug",
"description": "Bug report with attachments",
"attachment_uids": f"{u1},{u2}",
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert u1 in r.text and u2 in r.text, "both attachments must be linked and displayed on the bug"
assert u1 in r.text and u2 in r.text, (
"both attachments must be linked and displayed on the bug"
)
def test_message_links_multiple_attachments(app_server):
alice = _session()
bob, bob_name = _user("bob")
found = alice.get(f"{BASE_URL}/messages/search", params={"q": bob_name}).json()["results"]
found = alice.get(f"{BASE_URL}/messages/search", params={"q": bob_name}).json()[
"results"
]
bob_uid = found[0]["uid"]
u1, u2 = _upload(alice), _upload(alice)
r = alice.post(f"{BASE_URL}/messages/send", data={
"content": "Message with attachments", "receiver_uid": bob_uid,
"attachment_uids": f"{u1},{u2}",
}, allow_redirects=True)
r = alice.post(
f"{BASE_URL}/messages/send",
data={
"content": "Message with attachments",
"receiver_uid": bob_uid,
"attachment_uids": f"{u1},{u2}",
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert u1 in r.text and u2 in r.text, "both attachments must be linked and displayed in the conversation"
assert u1 in r.text and u2 in r.text, (
"both attachments must be linked and displayed in the conversation"
)
def test_single_attachment_links_to_post(app_server):
s = _session()
u1 = _upload(s)
r = s.post(f"{BASE_URL}/posts/create", data={
"content": "Post body with one attachment", "title": "one", "topic": "random",
"attachment_uids": u1,
}, allow_redirects=True)
r = s.post(
f"{BASE_URL}/posts/create",
data={
"content": "Post body with one attachment",
"title": "one",
"topic": "random",
"attachment_uids": u1,
},
allow_redirects=True,
)
assert r.status_code == 200, r.text[:300]
assert u1 in r.text, "single attachment must be linked and displayed"
+43 -13
View File
@@ -1,4 +1,12 @@
from devplacepy.utils import hash_password, verify_password, generate_uid, slugify, time_ago, level_for_xp, badge_info
from devplacepy.utils import (
hash_password,
verify_password,
generate_uid,
slugify,
time_ago,
level_for_xp,
badge_info,
)
from datetime import datetime, timedelta, timezone
@@ -107,8 +115,13 @@ def test_badge_info_unknown_fallback():
from devplacepy.utils import (
safe_next, strip_html, extract_mentions, award_badge, award_xp,
check_milestone_badges, create_mention_notifications,
safe_next,
strip_html,
extract_mentions,
award_badge,
award_xp,
check_milestone_badges,
create_mention_notifications,
)
from devplacepy.database import get_table
@@ -116,11 +129,17 @@ from devplacepy.database import get_table
def _seed_user(role="Member", xp=0, level=1):
uid = generate_uid()
username = f"ut_{uid[:8]}"
get_table("users").insert({
"uid": uid, "username": username, "email": f"{username}@t.dev",
"role": role, "xp": xp, "level": level,
"created_at": datetime.now(timezone.utc).isoformat(),
})
get_table("users").insert(
{
"uid": uid,
"username": username,
"email": f"{username}@t.dev",
"role": role,
"xp": xp,
"level": level,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid, username
@@ -161,10 +180,20 @@ def test_check_milestone_badges_awards_prolific(local_db):
posts = get_table("posts")
for index in range(10):
post_uid = generate_uid()
posts.insert({"uid": post_uid, "user_uid": uid, "slug": f"{post_uid[:8]}-p",
"title": None, "content": f"milestone post {index}", "topic": "random",
"project_uid": None, "image": None, "stars": 0,
"created_at": datetime.now(timezone.utc).isoformat()})
posts.insert(
{
"uid": post_uid,
"user_uid": uid,
"slug": f"{post_uid[:8]}-p",
"title": None,
"content": f"milestone post {index}",
"topic": "random",
"project_uid": None,
"image": None,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
awarded = check_milestone_badges(uid)
assert "Prolific" in awarded
assert get_table("badges").find_one(user_uid=uid, badge_name="Prolific") is not None
@@ -175,7 +204,8 @@ def test_create_mention_notifications_targets_known_users(local_db):
target_uid, target_name = _seed_user()
create_mention_notifications(
f"hey @{target_name} and @{actor_name} and @ghost_zzz",
actor_uid, "/posts/x",
actor_uid,
"/posts/x",
)
assert get_table("notifications").count(user_uid=target_uid, type="mention") == 1
assert get_table("notifications").count(user_uid=actor_uid, type="mention") == 0
+45 -14
View File
@@ -6,45 +6,76 @@ from tests.conftest import BASE_URL
def _session():
s = requests.Session()
name = f"val_{int(time.time() * 1000)}"
s.post(f"{BASE_URL}/auth/signup", data={
"username": name, "email": f"{name}@t.dev",
"password": "secret123", "confirm_password": "secret123",
}, allow_redirects=True)
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s
def test_vote_bad_value_is_handled_not_500(app_server):
s = _session()
r = s.post(f"{BASE_URL}/votes/post/nonexistent", data={"value": "abc"}, allow_redirects=False)
r = s.post(
f"{BASE_URL}/votes/post/nonexistent",
data={"value": "abc"},
allow_redirects=False,
)
assert r.status_code != 500
assert r.status_code in (302, 303, 400)
def test_oversized_post_content_rejected(app_server):
s = _session()
r = s.post(f"{BASE_URL}/posts/create", data={"content": "x" * 2001, "topic": "random"}, allow_redirects=False)
r = s.post(
f"{BASE_URL}/posts/create",
data={"content": "x" * 2001, "topic": "random"},
allow_redirects=False,
)
assert r.status_code in (302, 303, 400)
assert "/posts/" not in (r.headers.get("location") or "")
def test_short_post_content_rejected(app_server):
s = _session()
r = s.post(f"{BASE_URL}/posts/create", data={"content": "short", "topic": "random"}, allow_redirects=False)
r = s.post(
f"{BASE_URL}/posts/create",
data={"content": "short", "topic": "random"},
allow_redirects=False,
)
assert "/posts/" not in (r.headers.get("location") or "")
def test_profile_update_overlong_bio_rejected(app_server):
s = _session()
r = s.post(f"{BASE_URL}/profile/update", data={
"bio": "x" * 600, "location": "", "git_link": "", "website": "",
}, allow_redirects=False)
r = s.post(
f"{BASE_URL}/profile/update",
data={
"bio": "x" * 600,
"location": "",
"git_link": "",
"website": "",
},
allow_redirects=False,
)
assert r.status_code in (302, 303, 400)
def test_signup_short_username_rerenders_with_message(app_server):
r = requests.post(f"{BASE_URL}/auth/signup", data={
"username": "ab", "email": "x@y.zz",
"password": "secret123", "confirm_password": "secret123",
}, allow_redirects=False)
r = requests.post(
f"{BASE_URL}/auth/signup",
data={
"username": "ab",
"email": "x@y.zz",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=False,
)
assert r.status_code == 400
assert "Username must be between 3 and 32 characters" in r.text
+27 -11
View File
@@ -15,10 +15,16 @@ def _session():
_counter[0] += 1
name = f"vot{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": "secret123", "confirm_password": "secret123",
}, allow_redirects=True)
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
@@ -28,12 +34,20 @@ def _uid(username):
def _make_post(owner_uid):
uid = generate_uid()
get_table("posts").insert({
"uid": uid, "user_uid": owner_uid, "slug": f"{uid[:8]}-vote-post",
"title": None, "content": "vote target content", "topic": "random",
"project_uid": None, "image": None, "stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
})
get_table("posts").insert(
{
"uid": uid,
"user_uid": owner_uid,
"slug": f"{uid[:8]}-vote-post",
"title": None,
"content": "vote target content",
"topic": "random",
"project_uid": None,
"image": None,
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return uid
@@ -60,7 +74,9 @@ def test_switch_upvote_to_downvote(app_server):
s_b, _ = _session()
post_uid = _make_post(_uid(a_name))
s_b.post(f"{BASE_URL}/votes/post/{post_uid}", data={"value": "1"}, headers=AJAX)
r = s_b.post(f"{BASE_URL}/votes/post/{post_uid}", data={"value": "-1"}, headers=AJAX)
r = s_b.post(
f"{BASE_URL}/votes/post/{post_uid}", data={"value": "-1"}, headers=AJAX
)
assert r.json()["net"] == -1
assert r.json()["value"] == -1
+6 -2
View File
@@ -1,8 +1,12 @@
from tests.conftest import BASE_URL
from tests.test_post import create_post
POST_PAYLOAD = "XSSPROBE <img src=x onerror=alert('p')><script>alert('s')</script> end of post"
COMMENT_PAYLOAD = "CMTPROBE <img src=x onerror=alert('c')><script>alert('s')</script> end of comment"
POST_PAYLOAD = (
"XSSPROBE <img src=x onerror=alert('p')><script>alert('s')</script> end of post"
)
COMMENT_PAYLOAD = (
"CMTPROBE <img src=x onerror=alert('c')><script>alert('s')</script> end of comment"
)
def test_post_content_is_sanitized(alice):
+93 -28
View File
@@ -21,10 +21,16 @@ def _signup():
_counter[0] += 1
name = f"zd{int(time.time() * 1000)}{_counter[0]}"
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)
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
refresh_snapshot()
key = get_table("users").find_one(username=name)["api_key"]
return name, key
@@ -35,17 +41,27 @@ def _h(key):
def _create_project(key, title):
r = requests.post(f"{BASE_URL}/projects/create", headers=_h(key), data={
"title": title, "description": "zip download test",
"project_type": "software", "status": "In Development",
})
r = requests.post(
f"{BASE_URL}/projects/create",
headers=_h(key),
data={
"title": title,
"description": "zip download test",
"project_type": "software",
"status": "In Development",
},
)
assert r.status_code == 200, r.text
return r.json()["data"]
def _write(key, slug, path, content):
r = requests.post(f"{BASE_URL}/projects/{slug}/files/write", headers=_h(key),
data={"path": path, "content": content}, allow_redirects=False)
r = requests.post(
f"{BASE_URL}/projects/{slug}/files/write",
headers=_h(key),
data={"path": path, "content": content},
allow_redirects=False,
)
assert r.status_code in (200, 302), r.text
@@ -59,6 +75,7 @@ def _process_uid(uid):
if job and job["status"] in ("done", "failed"):
return
await asyncio.sleep(0.05)
run_async(drive())
@@ -68,15 +85,24 @@ def _drain_pending():
appeared = False
for _ in range(600):
refresh_snapshot()
pending = [r for r in get_table("jobs").find(kind="zip") if r["status"] in ("pending", "running")]
pending = [
r
for r in get_table("jobs").find(kind="zip")
if r["status"] in ("pending", "running")
]
if pending:
appeared = True
await svc.run_once()
refresh_snapshot()
pending = [r for r in get_table("jobs").find(kind="zip") if r["status"] in ("pending", "running")]
pending = [
r
for r in get_table("jobs").find(kind="zip")
if r["status"] in ("pending", "running")
]
if appeared and not pending and not svc._inflight:
return
await asyncio.sleep(0.05)
run_async(drive())
@@ -93,6 +119,7 @@ def _cleanup_archives():
# ---------------- HTTP enqueue / status / download ----------------
def test_enqueue_project_zip_returns_uid(app_server):
try:
_, key = _signup()
@@ -114,15 +141,21 @@ def test_status_pending_then_done_then_download(app_server):
slug = proj["slug"] or proj["uid"]
_write(key, slug, "README.md", "# hello")
_write(key, slug, "src/app.py", "print(1)\n")
uid = requests.post(f"{BASE_URL}/projects/{slug}/zip", headers=_h(key)).json()["uid"]
uid = requests.post(f"{BASE_URL}/projects/{slug}/zip", headers=_h(key)).json()[
"uid"
]
pending = requests.get(f"{BASE_URL}/zips/{uid}", headers={"Accept": "application/json"}).json()
pending = requests.get(
f"{BASE_URL}/zips/{uid}", headers={"Accept": "application/json"}
).json()
assert pending["status"] in ("pending", "running")
assert pending["download_url"] is None
_process_uid(uid)
done = requests.get(f"{BASE_URL}/zips/{uid}", headers={"Accept": "application/json"}).json()
done = requests.get(
f"{BASE_URL}/zips/{uid}", headers={"Accept": "application/json"}
).json()
assert done["status"] == "done", done
assert done["download_url"] == f"/zips/{uid}/download"
assert done["file_count"] == 2 and done["dir_count"] == 1
@@ -145,9 +178,15 @@ def test_files_zip_subtree_download(app_server):
slug = proj["slug"] or proj["uid"]
_write(key, slug, "src/app.py", "print(1)\n")
_write(key, slug, "docs/readme.md", "x")
uid = requests.post(f"{BASE_URL}/projects/{slug}/files/zip", headers=_h(key), params={"path": "src"}).json()["uid"]
uid = requests.post(
f"{BASE_URL}/projects/{slug}/files/zip",
headers=_h(key),
params={"path": "src"},
).json()["uid"]
_process_uid(uid)
done = requests.get(f"{BASE_URL}/zips/{uid}", headers={"Accept": "application/json"}).json()
done = requests.get(
f"{BASE_URL}/zips/{uid}", headers={"Accept": "application/json"}
).json()
dl = requests.get(f"{BASE_URL}{done['download_url']}")
names = sorted(zipfile.ZipFile(io.BytesIO(dl.content)).namelist())
assert names == ["src/", "src/app.py"]
@@ -161,7 +200,9 @@ def test_capability_download_works_without_auth(app_server):
proj = _create_project(key, "Capability")
slug = proj["slug"] or proj["uid"]
_write(key, slug, "README.md", "# hi")
uid = requests.post(f"{BASE_URL}/projects/{slug}/zip", headers=_h(key)).json()["uid"]
uid = requests.post(f"{BASE_URL}/projects/{slug}/zip", headers=_h(key)).json()[
"uid"
]
_process_uid(uid)
# No auth header at all - the uuid7 is the capability token.
dl = requests.get(f"{BASE_URL}/zips/{uid}/download")
@@ -173,23 +214,32 @@ def test_capability_download_works_without_auth(app_server):
# ---------------- guards ----------------
def test_files_zip_rejects_traversal(app_server):
_, key = _signup()
proj = _create_project(key, "Traversal Guard")
slug = proj["slug"] or proj["uid"]
r = requests.post(f"{BASE_URL}/projects/{slug}/files/zip", headers=_h(key), params={"path": "../../etc/passwd"})
r = requests.post(
f"{BASE_URL}/projects/{slug}/files/zip",
headers=_h(key),
params={"path": "../../etc/passwd"},
)
assert r.status_code == 400, r.text
def test_status_unknown_uid_404(app_server):
r = requests.get(f"{BASE_URL}/zips/nope-not-a-job", headers={"Accept": "application/json"})
r = requests.get(
f"{BASE_URL}/zips/nope-not-a-job", headers={"Accept": "application/json"}
)
assert r.status_code == 404
def test_status_rejects_non_zip_kind(app_server):
try:
uid = queue.enqueue("other", {}, "user", "u", "x")
r = requests.get(f"{BASE_URL}/zips/{uid}", headers={"Accept": "application/json"})
r = requests.get(
f"{BASE_URL}/zips/{uid}", headers={"Accept": "application/json"}
)
assert r.status_code == 404
finally:
get_table("jobs").delete(kind="other")
@@ -201,7 +251,9 @@ def test_download_pending_job_404(app_server):
proj = _create_project(key, "Pending Download")
slug = proj["slug"] or proj["uid"]
_write(key, slug, "README.md", "# hi")
uid = requests.post(f"{BASE_URL}/projects/{slug}/zip", headers=_h(key)).json()["uid"]
uid = requests.post(f"{BASE_URL}/projects/{slug}/zip", headers=_h(key)).json()[
"uid"
]
r = requests.get(f"{BASE_URL}/zips/{uid}/download")
assert r.status_code == 404
finally:
@@ -211,11 +263,21 @@ def test_download_pending_job_404(app_server):
def test_download_path_outside_root_blocked(app_server):
try:
uid = queue.enqueue("zip", {}, "user", "u", "evil")
get_table("jobs").update({
"uid": uid, "status": "done",
"result": json.dumps({"local_path": "/etc/passwd", "final_name": "passwd.zip", "download_url": f"/zips/{uid}/download"}),
"expires_at": "2999-01-01T00:00:00+00:00",
}, ["uid"])
get_table("jobs").update(
{
"uid": uid,
"status": "done",
"result": json.dumps(
{
"local_path": "/etc/passwd",
"final_name": "passwd.zip",
"download_url": f"/zips/{uid}/download",
}
),
"expires_at": "2999-01-01T00:00:00+00:00",
},
["uid"],
)
r = requests.get(f"{BASE_URL}/zips/{uid}/download")
assert r.status_code == 404
finally:
@@ -224,6 +286,7 @@ def test_download_path_outside_root_blocked(app_server):
# ---------------- Playwright UI ----------------
def _login(page, name):
page.goto(f"{BASE_URL}/auth/login", wait_until="domcontentloaded")
page.fill("#email", f"{name}@t.dev")
@@ -270,4 +333,6 @@ def test_files_context_menu_has_download(app_server, page):
row = page.locator(".pf-node-row").first
row.wait_for(state="visible")
row.click(button="right")
expect(page.locator(".context-menu-item:has-text('Download as zip')").first).to_be_visible()
expect(
page.locator(".context-menu-item:has-text('Download as zip')").first
).to_be_visible()
+159 -40
View File
@@ -22,8 +22,12 @@ def _init_db():
@pytest.fixture
def zip_env(tmp_path, monkeypatch):
monkeypatch.setattr("devplacepy.services.jobs.zip_service.ZIPS_DIR", tmp_path / "zips")
monkeypatch.setattr("devplacepy.services.jobs.zip_service.STAGING_DIR", tmp_path / "staging")
monkeypatch.setattr(
"devplacepy.services.jobs.zip_service.ZIPS_DIR", tmp_path / "zips"
)
monkeypatch.setattr(
"devplacepy.services.jobs.zip_service.STAGING_DIR", tmp_path / "staging"
)
monkeypatch.setattr("devplacepy.project_files.PROJECT_FILES_DIR", tmp_path / "pf")
yield tmp_path
jobs = get_table("jobs")
@@ -55,15 +59,21 @@ def _process_zip_jobs():
for _ in range(400):
await svc.run_once()
refresh_snapshot()
pending = [r for r in get_table("jobs").find(kind="zip") if r["status"] in ("pending", "running")]
pending = [
r
for r in get_table("jobs").find(kind="zip")
if r["status"] in ("pending", "running")
]
if not pending and not svc._inflight:
return
await asyncio.sleep(0.05)
run_async(drive())
# ---------------- queue helpers ----------------
def test_enqueue_creates_pending_job(zip_env):
uid = queue.enqueue("zip", {"a": 1}, "user", "u1", "Name")
job = queue.get_job(uid)
@@ -95,6 +105,7 @@ def test_list_jobs_filters(zip_env):
# ---------------- zip_worker subprocess body ----------------
def test_zip_worker_stats_and_crc(tmp_path):
src = tmp_path / "src"
(src / "sub").mkdir(parents=True)
@@ -116,9 +127,16 @@ def test_zip_worker_stats_and_crc(tmp_path):
# ---------------- ZipService.process ----------------
def test_process_whole_project(zip_env):
pid, _ = _make_project(binary=True)
uid = queue.enqueue("zip", {"source": {"type": "project_tree", "project_uid": pid, "path": ""}}, "user", "u", "Proj")
uid = queue.enqueue(
"zip",
{"source": {"type": "project_tree", "project_uid": pid, "path": ""}},
"user",
"u",
"Proj",
)
_process_zip_jobs()
job = queue.get_job(uid)
assert job["status"] == "done"
@@ -133,15 +151,30 @@ def test_process_whole_project(zip_env):
def test_process_subtree_folder(zip_env):
pid, _ = _make_project()
uid = queue.enqueue("zip", {"source": {"type": "project_tree", "project_uid": pid, "path": "src"}}, "user", "u", "src")
uid = queue.enqueue(
"zip",
{"source": {"type": "project_tree", "project_uid": pid, "path": "src"}},
"user",
"u",
"src",
)
_process_zip_jobs()
result = queue.get_job(uid)["result"]
assert sorted(zipfile.ZipFile(result["local_path"]).namelist()) == ["src/", "src/app.py"]
assert sorted(zipfile.ZipFile(result["local_path"]).namelist()) == [
"src/",
"src/app.py",
]
def test_process_single_file(zip_env):
pid, _ = _make_project()
uid = queue.enqueue("zip", {"source": {"type": "project_tree", "project_uid": pid, "path": "src/app.py"}}, "user", "u", "app.py")
uid = queue.enqueue(
"zip",
{"source": {"type": "project_tree", "project_uid": pid, "path": "src/app.py"}},
"user",
"u",
"app.py",
)
_process_zip_jobs()
result = queue.get_job(uid)["result"]
assert zipfile.ZipFile(result["local_path"]).namelist() == ["app.py"]
@@ -149,7 +182,13 @@ def test_process_single_file(zip_env):
def test_final_name_format_and_strip_zip(zip_env):
pid, _ = _make_project()
uid = queue.enqueue("zip", {"source": {"type": "project_tree", "project_uid": pid, "path": ""}}, "user", "u", "My Project.ZIP")
uid = queue.enqueue(
"zip",
{"source": {"type": "project_tree", "project_uid": pid, "path": ""}},
"user",
"u",
"My Project.ZIP",
)
_process_zip_jobs()
result = queue.get_job(uid)["result"]
name = result["final_name"]
@@ -161,7 +200,13 @@ def test_final_name_format_and_strip_zip(zip_env):
def test_blank_preferred_name_defaults(zip_env):
pid, _ = _make_project()
uid = queue.enqueue("zip", {"source": {"type": "project_tree", "project_uid": pid, "path": ""}}, "user", "u", "")
uid = queue.enqueue(
"zip",
{"source": {"type": "project_tree", "project_uid": pid, "path": ""}},
"user",
"u",
"",
)
_process_zip_jobs()
assert queue.get_job(uid)["result"]["final_name"].endswith(".download.zip")
@@ -180,7 +225,13 @@ def test_identical_content_replaces_same_name(zip_env):
def test_cleanup_removes_artifact(zip_env):
pid, _ = _make_project()
uid = queue.enqueue("zip", {"source": {"type": "project_tree", "project_uid": pid, "path": ""}}, "user", "u", "thing")
uid = queue.enqueue(
"zip",
{"source": {"type": "project_tree", "project_uid": pid, "path": ""}},
"user",
"u",
"thing",
)
_process_zip_jobs()
job = queue.get_job(uid)
path = Path(job["result"]["local_path"])
@@ -199,6 +250,7 @@ def test_unsupported_source_fails_job(zip_env):
# ---------------- path traversal safety ----------------
def test_normalize_path_rejects_parent(zip_env):
with pytest.raises(ProjectFileError):
project_files.normalize_path("a/../../b")
@@ -208,13 +260,25 @@ def test_normalize_path_rejects_parent(zip_env):
def test_export_blocks_malicious_db_path(zip_env, tmp_path):
pid = "ziptest-evil"
get_table("project_files").insert({
"uid": "evil-node", "project_uid": pid, "user_uid": "u",
"path": "../escape.txt", "name": "escape.txt", "parent_path": "",
"type": "file", "content": "pwned", "is_binary": 0,
"stored_name": None, "directory": None, "mime_type": "text/plain",
"size": 5, "created_at": "x", "updated_at": "x",
})
get_table("project_files").insert(
{
"uid": "evil-node",
"project_uid": pid,
"user_uid": "u",
"path": "../escape.txt",
"name": "escape.txt",
"parent_path": "",
"type": "file",
"content": "pwned",
"is_binary": 0,
"stored_name": None,
"directory": None,
"mime_type": "text/plain",
"size": 5,
"created_at": "x",
"updated_at": "x",
}
)
dest = tmp_path / "dest"
with pytest.raises(ProjectFileError):
project_files.export_to_dir(pid, "", dest)
@@ -223,23 +287,51 @@ def test_export_blocks_malicious_db_path(zip_env, tmp_path):
def test_traversal_payload_marks_job_failed(zip_env):
pid = "ziptest-evil2"
get_table("project_files").insert({
"uid": "evil-node2", "project_uid": pid, "user_uid": "u",
"path": "../../escape2.txt", "name": "escape2.txt", "parent_path": "",
"type": "file", "content": "pwned", "is_binary": 0,
"stored_name": None, "directory": None, "mime_type": "text/plain",
"size": 6, "created_at": "x", "updated_at": "x",
})
uid = queue.enqueue("zip", {"source": {"type": "project_tree", "project_uid": pid, "path": ""}}, "user", "u", "evil")
get_table("project_files").insert(
{
"uid": "evil-node2",
"project_uid": pid,
"user_uid": "u",
"path": "../../escape2.txt",
"name": "escape2.txt",
"parent_path": "",
"type": "file",
"content": "pwned",
"is_binary": 0,
"stored_name": None,
"directory": None,
"mime_type": "text/plain",
"size": 6,
"created_at": "x",
"updated_at": "x",
}
)
uid = queue.enqueue(
"zip",
{"source": {"type": "project_tree", "project_uid": pid, "path": ""}},
"user",
"u",
"evil",
)
_process_zip_jobs()
assert queue.get_job(uid)["status"] == "failed"
# ---------------- JobService lifecycle ----------------
def test_orphan_running_recovered_on_enable(zip_env):
uid = queue.enqueue("zip", {"source": {"type": "project_tree", "project_uid": "p", "path": ""}}, "user", "u", "n")
get_table("jobs").update({"uid": uid, "status": "running", "started_at": "2020-01-01T00:00:00+00:00"}, ["uid"])
uid = queue.enqueue(
"zip",
{"source": {"type": "project_tree", "project_uid": "p", "path": ""}},
"user",
"u",
"n",
)
get_table("jobs").update(
{"uid": uid, "status": "running", "started_at": "2020-01-01T00:00:00+00:00"},
["uid"],
)
svc = ZipService()
run_async(svc.on_enable())
job = queue.get_job(uid)
@@ -248,9 +340,22 @@ def test_orphan_running_recovered_on_enable(zip_env):
def test_orphan_exceeds_retry_limit_fails(zip_env):
uid = queue.enqueue("zip", {"source": {"type": "project_tree", "project_uid": "p", "path": ""}}, "user", "u", "n")
uid = queue.enqueue(
"zip",
{"source": {"type": "project_tree", "project_uid": "p", "path": ""}},
"user",
"u",
"n",
)
get_table("jobs").update(
{"uid": uid, "status": "running", "retry_count": 3, "started_at": "2020-01-01T00:00:00+00:00"}, ["uid"])
{
"uid": uid,
"status": "running",
"retry_count": 3,
"started_at": "2020-01-01T00:00:00+00:00",
},
["uid"],
)
svc = ZipService()
run_async(svc.on_enable())
assert queue.get_job(uid)["status"] == "failed"
@@ -260,11 +365,15 @@ def test_retention_sweep_deletes_expired(zip_env, tmp_path):
artifact = tmp_path / "old.zip"
artifact.write_bytes(b"PK\x05\x06" + b"\x00" * 18)
uid = queue.enqueue("zip", {}, "user", "u", "old")
get_table("jobs").update({
"uid": uid, "status": "done",
"result": '{"local_path": "%s"}' % artifact.as_posix(),
"expires_at": "2000-01-01T00:00:00+00:00",
}, ["uid"])
get_table("jobs").update(
{
"uid": uid,
"status": "done",
"result": '{"local_path": "%s"}' % artifact.as_posix(),
"expires_at": "2000-01-01T00:00:00+00:00",
},
["uid"],
)
svc = ZipService()
run_async(svc.run_once())
refresh_snapshot()
@@ -274,11 +383,15 @@ def test_retention_sweep_deletes_expired(zip_env, tmp_path):
def test_retention_keeps_unexpired(zip_env, tmp_path):
uid = queue.enqueue("zip", {}, "user", "u", "fresh")
get_table("jobs").update({
"uid": uid, "status": "done",
"result": "{}",
"expires_at": "2999-01-01T00:00:00+00:00",
}, ["uid"])
get_table("jobs").update(
{
"uid": uid,
"status": "done",
"result": "{}",
"expires_at": "2999-01-01T00:00:00+00:00",
},
["uid"],
)
svc = ZipService()
run_async(svc.run_once())
refresh_snapshot()
@@ -287,7 +400,13 @@ def test_retention_keeps_unexpired(zip_env, tmp_path):
def test_max_concurrent_caps_inflight(zip_env, monkeypatch):
for _ in range(4):
queue.enqueue("zip", {"source": {"type": "project_tree", "project_uid": "p", "path": ""}}, "user", "u", "n")
queue.enqueue(
"zip",
{"source": {"type": "project_tree", "project_uid": "p", "path": ""}},
"user",
"u",
"n",
)
async def fake_process(self, job):
await asyncio.sleep(0.2)