forked from retoor/devplacepy
fix: normalize unicode escape sequences and reformat multi-line expressions across codebase
This commit is contained in:
+69
-21
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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> & clean",
|
||||
"content": "<p>Body</p>",
|
||||
})
|
||||
get_table("news").insert(
|
||||
{
|
||||
"uid": uid,
|
||||
"description": "<b>Bold</b> & 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
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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="")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user