chore: reorganize test files into domain-specific subdirectories under tests/
Split the monolithic test directory into three tiers (unit, api, e2e) with a path-mirroring directory structure. Added corresponding Makefile targets (test-unit, test-api, test-e2e) and updated all documentation references (CLAUDE.md, README.md, testing-cicd.html, testing-framework.html, testing-make.html) to reflect the new layout and naming conventions.
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from starlette.requests import Request
|
||||
from tests.conftest import BASE_URL
|
||||
from devplacepy.database import get_table, set_setting
|
||||
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,
|
||||
)
|
||||
def _now_iso_ai_usage_profile():
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
def _make_user_ai_usage_profile(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,
|
||||
}
|
||||
)
|
||||
return uid, username, api_key
|
||||
def _make_request_ai_usage_profile(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": {},
|
||||
}
|
||||
)
|
||||
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_ai_usage_profile(),
|
||||
"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",
|
||||
}
|
||||
def _goto_profile(page, username):
|
||||
page.goto(f"{BASE_URL}/profile/{username}", wait_until="domcontentloaded")
|
||||
|
||||
|
||||
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"
|
||||
]
|
||||
resp = page.request.get(f"{BASE_URL}/admin/users/{alice_uid}/ai-usage?hours=24")
|
||||
assert resp.status == 200
|
||||
data = resp.json()
|
||||
assert data["owner_id"] == alice_uid
|
||||
assert "cost" in data and "requests" in data and "tokens" in data
|
||||
|
||||
|
||||
def test_member_forbidden_from_user_ai_usage_endpoint(bob, seeded_db):
|
||||
page, _ = bob
|
||||
bob_uid = get_table("users").find_one(username=seeded_db["bob"]["username"])["uid"]
|
||||
resp = page.request.get(f"{BASE_URL}/admin/users/{bob_uid}/ai-usage")
|
||||
assert resp.status == 403
|
||||
@@ -0,0 +1,366 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from uuid import uuid4
|
||||
from datetime import datetime, timezone
|
||||
from tests.conftest import BASE_URL
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.utils import make_combined_slug
|
||||
def seed_admin_news(count=3):
|
||||
news_table = get_table("news")
|
||||
news_table.delete()
|
||||
for i in range(count):
|
||||
eid = f"admin_test_news_{i}"
|
||||
if news_table.find_one(external_id=eid):
|
||||
continue
|
||||
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.",
|
||||
}
|
||||
)
|
||||
def seed_extra_users(count=30):
|
||||
users = get_table("users")
|
||||
existing_count = len(list(users.all()))
|
||||
if existing_count > 5:
|
||||
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(),
|
||||
}
|
||||
)
|
||||
def _seed_target_user():
|
||||
uid = str(uuid4())
|
||||
get_table("users").insert(
|
||||
{
|
||||
"uid": uid,
|
||||
"username": f"target_{uid[:8]}",
|
||||
"email": f"target_{uid[:8]}@test.devplace",
|
||||
"password_hash": "x",
|
||||
"role": "Member",
|
||||
"is_active": True,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
return uid
|
||||
import time
|
||||
import pytest
|
||||
import requests
|
||||
from devplacepy.database import get_table, refresh_snapshot, set_setting
|
||||
from devplacepy.utils import generate_uid, make_combined_slug
|
||||
JSON_audit_log = {"Accept": "application/json"}
|
||||
_counter_audit_log = [0]
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def _audit_test_settings(app_server):
|
||||
# On a fresh DB init_db skips seeding the operational/upload settings (its
|
||||
# `tables` snapshot predates site_settings creation), so those rows are
|
||||
# absent and the admin settings form would INSERT them as "" - which both
|
||||
# closes registration and makes consumers that do int("") crash. Seed sane
|
||||
# values here so the form's empty submissions are skipped (existing key), and
|
||||
# lift the per-IP rate limit since this file fires many mutating requests.
|
||||
for key, value in {
|
||||
"rate_limit_per_minute": "1000000",
|
||||
"rate_limit_window_seconds": "60",
|
||||
"registration_open": "1",
|
||||
"maintenance_mode": "0",
|
||||
"max_upload_size_mb": "10",
|
||||
"allowed_file_types": "",
|
||||
"max_attachments_per_resource": "10",
|
||||
"session_max_age_days": "7",
|
||||
"session_remember_days": "30",
|
||||
"news_service_interval": "3600",
|
||||
"news_grade_threshold": "7",
|
||||
}.items():
|
||||
set_setting(key, value)
|
||||
yield
|
||||
def _db_user(name):
|
||||
# the user is created by the server subprocess; refresh the test-process
|
||||
# SQLite snapshot before reading it back across the process boundary.
|
||||
refresh_snapshot()
|
||||
return get_table("users").find_one(username=name)
|
||||
def _unique(prefix="au"):
|
||||
_counter_audit_log[0] += 1
|
||||
return f"{prefix}{int(time.time() * 1000)}{_counter_audit_log[0]}"
|
||||
def _member():
|
||||
name = _unique("aumem")
|
||||
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,
|
||||
)
|
||||
return s, name
|
||||
def _member_key():
|
||||
_, name = _member()
|
||||
return _db_user(name)["api_key"]
|
||||
def _admin(seeded_db):
|
||||
# authenticate via the seeded admin's API key (header auth) rather than a
|
||||
# login POST - GET reads are exempt from the rate limiter, so reusing this
|
||||
# across the file's many tests never counts against the per-IP write budget.
|
||||
key = _db_user("alice_test")["api_key"]
|
||||
s = requests.Session()
|
||||
s.headers.update({"X-API-KEY": key})
|
||||
return s
|
||||
def _audit(admin, **params):
|
||||
r = admin.get(f"{BASE_URL}/admin/audit-log", headers=JSON_audit_log, params=params)
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
return r.json()
|
||||
def _find(admin, event_key, predicate):
|
||||
data = _audit(admin, event_key=event_key)
|
||||
for entry in data["entries"]:
|
||||
if predicate(entry):
|
||||
return entry
|
||||
return None
|
||||
def _new_post(session, body="audited post body here"):
|
||||
return session.post(
|
||||
f"{BASE_URL}/posts/create",
|
||||
headers=JSON_audit_log,
|
||||
data={"title": _unique("aup"), "content": body, "topic": "devlog"},
|
||||
).json()["data"]
|
||||
def _new_project(session):
|
||||
return session.post(
|
||||
f"{BASE_URL}/projects/create",
|
||||
headers=JSON_audit_log,
|
||||
data={
|
||||
"title": _unique("aupr"),
|
||||
"description": "audited project description text",
|
||||
"project_type": "software",
|
||||
"status": "In Development",
|
||||
"platforms": "",
|
||||
},
|
||||
).json()["data"]
|
||||
def _seed_news_audit_log():
|
||||
uid = generate_uid()
|
||||
title = _unique("aunews")
|
||||
get_table("news").insert(
|
||||
{
|
||||
"uid": uid,
|
||||
"slug": make_combined_slug(title, uid),
|
||||
"title": title,
|
||||
"external_id": uid,
|
||||
"status": "draft",
|
||||
"featured": 0,
|
||||
"show_on_landing": 0,
|
||||
"grade": 5,
|
||||
"source_name": "AuditTest",
|
||||
"synced_at": datetime.now(timezone.utc).isoformat(),
|
||||
"description": "audited news article",
|
||||
}
|
||||
)
|
||||
refresh_snapshot()
|
||||
return uid
|
||||
import io
|
||||
import uuid
|
||||
from PIL import Image
|
||||
JSON_media = {"Accept": "application/json"}
|
||||
def _png_bytes_media(color=(200, 30, 30)):
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (8, 8), color).save(buf, "PNG")
|
||||
return buf.getvalue()
|
||||
def _signup_media(prefix="media"):
|
||||
s = requests.Session()
|
||||
name = f"{prefix}_{uuid.uuid4().hex[:10]}"
|
||||
s.post(
|
||||
f"{BASE_URL}/auth/signup",
|
||||
data={
|
||||
"username": name,
|
||||
"email": f"{name}@test.devplace",
|
||||
"password": "secret123",
|
||||
"confirm_password": "secret123",
|
||||
},
|
||||
allow_redirects=True,
|
||||
)
|
||||
return s, name
|
||||
def _login_media(seeded_db, who):
|
||||
s = requests.Session()
|
||||
creds = seeded_db[who]
|
||||
s.post(
|
||||
f"{BASE_URL}/auth/login",
|
||||
data={"email": creds["email"], "password": creds["password"]},
|
||||
allow_redirects=True,
|
||||
)
|
||||
return s
|
||||
def _upload_media(s, name="pic.png", content=None, mime="image/png"):
|
||||
files = {"file": (name, content if content is not None else _png_bytes_media(), mime)}
|
||||
r = s.post(f"{BASE_URL}/uploads/upload", files=files)
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()["uid"]
|
||||
def _create_post_media(s, attachment_uids, title="media post"):
|
||||
r = s.post(
|
||||
f"{BASE_URL}/posts/create",
|
||||
data={
|
||||
"content": "A post that carries media for the gallery tests.",
|
||||
"title": title,
|
||||
"topic": "random",
|
||||
"attachment_uids": attachment_uids,
|
||||
},
|
||||
allow_redirects=True,
|
||||
)
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
assert "/posts/" in r.url, r.url
|
||||
return r.url
|
||||
def _create_project_media(s, attachment_uids, title="Media Project"):
|
||||
r = s.post(
|
||||
f"{BASE_URL}/projects/create",
|
||||
data={
|
||||
"title": title,
|
||||
"description": "Project with media for gallery tests.",
|
||||
"project_type": "software",
|
||||
"platforms": "linux",
|
||||
"status": "In Development",
|
||||
"attachment_uids": attachment_uids,
|
||||
},
|
||||
allow_redirects=True,
|
||||
)
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
assert "/projects/" in r.url, r.url
|
||||
return r.url
|
||||
def _media_json(session, username):
|
||||
r = session.get(f"{BASE_URL}/profile/{username}?tab=media", headers=JSON_media)
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
return r.json()
|
||||
def _media_uids(session, username):
|
||||
return [m["uid"] for m in _media_json(session, username)["media"]]
|
||||
def _seed_media_via_browser(page, title="ui media"):
|
||||
up = page.request.post(
|
||||
f"{BASE_URL}/uploads/upload",
|
||||
multipart={
|
||||
"file": {
|
||||
"name": "pic.png",
|
||||
"mimeType": "image/png",
|
||||
"buffer": _png_bytes_media(),
|
||||
}
|
||||
},
|
||||
)
|
||||
assert up.ok, up.text()
|
||||
uid = up.json()["uid"]
|
||||
post = page.request.post(
|
||||
f"{BASE_URL}/posts/create",
|
||||
form={
|
||||
"content": "media for the ui gallery test",
|
||||
"title": title,
|
||||
"topic": "random",
|
||||
"attachment_uids": uid,
|
||||
},
|
||||
)
|
||||
assert post.ok, post.text()
|
||||
return uid
|
||||
def _media_uids_via_page(page, username):
|
||||
resp = page.request.get(f"{BASE_URL}/profile/{username}?tab=media", headers=JSON_media)
|
||||
return [m["uid"] for m in resp.json()["media"]]
|
||||
|
||||
|
||||
def test_admin_users_loads(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/admin/users", wait_until="domcontentloaded")
|
||||
assert page.is_visible("text=Users")
|
||||
assert page.is_visible("text=alice_test")
|
||||
|
||||
|
||||
def test_admin_users_pagination(alice):
|
||||
page, _ = alice
|
||||
import urllib.request
|
||||
import 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()
|
||||
try:
|
||||
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")
|
||||
assert "Page 1" in page.text_content(".pagination-info")
|
||||
page.goto(f"{BASE_URL}/admin/users?page=2", wait_until="domcontentloaded")
|
||||
assert "Page 2" in page.text_content(".pagination-info")
|
||||
|
||||
|
||||
def test_admin_change_user_role(alice):
|
||||
page, _ = alice
|
||||
uid = _seed_target_user()
|
||||
page.goto(f"{BASE_URL}/admin/users", wait_until="domcontentloaded")
|
||||
sel = f"form[action='/admin/users/{uid}/role'] select[name='role']"
|
||||
page.locator(sel).wait_for(state="visible")
|
||||
page.select_option(sel, "admin")
|
||||
page.locator(".dialog-overlay.visible .dialog-confirm").click()
|
||||
page.wait_for_url("**/admin/users**", wait_until="domcontentloaded")
|
||||
page.goto(f"{BASE_URL}/admin/users", wait_until="domcontentloaded")
|
||||
assert page.locator(sel).input_value() == "admin"
|
||||
|
||||
|
||||
def test_admin_toggle_user_active(alice):
|
||||
page, _ = alice
|
||||
uid = _seed_target_user()
|
||||
page.goto(f"{BASE_URL}/admin/users", wait_until="domcontentloaded")
|
||||
btn = f"form[action='/admin/users/{uid}/toggle'] button"
|
||||
before = page.locator(btn).inner_text()
|
||||
page.locator(btn).click()
|
||||
page.locator(".dialog-overlay.visible .dialog-confirm").click()
|
||||
page.wait_for_url("**/admin/users**", wait_until="domcontentloaded")
|
||||
assert page.locator(btn).inner_text() != before
|
||||
|
||||
|
||||
def test_admin_password_change_redirect(alice):
|
||||
page, _ = alice
|
||||
uid = _seed_target_user()
|
||||
page.goto(f"{BASE_URL}/admin/users", wait_until="domcontentloaded")
|
||||
form_sel = f"form[action='/admin/users/{uid}/password']"
|
||||
pw_form = page.locator(form_sel)
|
||||
if pw_form.count() > 0:
|
||||
page.locator(f"button[data-toggle='pw-{uid}']").click()
|
||||
pw_form.locator("input[name='password']").fill("newpass123")
|
||||
pw_form.locator("button[type='submit']").click()
|
||||
page.wait_for_url("**/admin/users**", wait_until="domcontentloaded")
|
||||
|
||||
|
||||
def test_audit_log_sidebar_link_present(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/admin/users", wait_until="domcontentloaded")
|
||||
link = page.locator(".sidebar-card a.sidebar-link[href='/admin/audit-log']")
|
||||
assert link.count() == 1
|
||||
assert "Audit Log" in link.inner_text()
|
||||
|
||||
|
||||
def test_ui_admin_media_sidebar_and_trash(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/admin/users", wait_until="domcontentloaded")
|
||||
link = page.locator(".sidebar-link[href='/admin/media']")
|
||||
assert link.is_visible()
|
||||
link.click()
|
||||
page.wait_for_url("**/admin/media", wait_until="domcontentloaded")
|
||||
assert page.is_visible("text=Media trash")
|
||||
Reference in New Issue
Block a user