forked from retoor/devplacepy
Implements the moderation and consent obligations a social platform carries, so the web version and any client that speaks to it enforce the same rules. Moderation core (services/moderation/, database/moderation.py): a reportable target registry, the content filter and its choke points, the report queue with atomic resolution, enforcement actions, consent tracking, maturity gating, and account deletion with a grace window. Surfaces: POST /reports plus the member report list, /admin/moderation and the per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and account deletion under /profile, the report button and dialog partials, the maturity gate, and the moderation stylesheet and ReportDialog client. Every user-generated surface stays reportable by construction: new content tables are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a reason, and the registry test fails the suite on anything left unclassified. Docs: community guidelines, content moderation, intellectual property, privacy, terms, contact, and the admin-only moderation operations page, plus the moderation API group and the Devii moderation actions. Compliance record: applecomp.md is the requirement register, applechanges.md the gap analysis against this codebase, and appleimpl.md the implementation design they resolve to. Tests cover the report flow, admin moderation, consent, account deletion, terms acceptance, workspaces, and the registry invariant across the unit, api, and e2e tiers.
476 lines
16 KiB
Python
476 lines
16 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
import asyncio
|
|
import pytest
|
|
from devplacepy.database import (
|
|
add_issue_usage,
|
|
get_issue_usage,
|
|
get_table,
|
|
init_db,
|
|
refresh_snapshot,
|
|
set_setting,
|
|
)
|
|
from devplacepy.services.gitea import runtime, store
|
|
from devplacepy.services.gitea.config import gitea_config
|
|
from devplacepy.services.gitea.enhance import enhance_ticket
|
|
from devplacepy.services.gitea.fake import FakeGiteaClient
|
|
from devplacepy.services.gitea.planning import (
|
|
collect_open_issues,
|
|
generate_plan,
|
|
verbatim_tickets,
|
|
)
|
|
from devplacepy.services.jobs.planning_service import PlanningReportService
|
|
from devplacepy.services.gitea.service import IssueTrackerService
|
|
from devplacepy.services.jobs import queue
|
|
from devplacepy.services.jobs.issue_create_service import IssueCreateService
|
|
from devplacepy.services.openai_gateway.usage import new_usage_totals
|
|
from tests.conftest import run_async
|
|
|
|
_GATEWAY_HEADERS = {
|
|
"X-Gateway-Cost-USD": "0.00010000",
|
|
"X-Gateway-Model": "molodetz",
|
|
"X-Gateway-Prompt-Tokens": "500",
|
|
"X-Gateway-Completion-Tokens": "100",
|
|
"X-Gateway-Total-Tokens": "600",
|
|
"X-Gateway-Upstream-Latency-Ms": "400",
|
|
"X-Gateway-Total-Latency-Ms": "450",
|
|
}
|
|
|
|
|
|
class _FakeGatewayResp:
|
|
def __init__(self, content, headers):
|
|
self._content = content
|
|
self.headers = headers
|
|
|
|
def raise_for_status(self):
|
|
return None
|
|
|
|
def json(self):
|
|
return {"choices": [{"message": {"content": self._content}}]}
|
|
|
|
|
|
class _FakeGatewayClient:
|
|
def __init__(self, content, headers):
|
|
self._content = content
|
|
self._headers = headers
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *exc):
|
|
return False
|
|
|
|
async def post(self, *args, **kwargs):
|
|
return _FakeGatewayResp(self._content, self._headers)
|
|
|
|
|
|
def _fake_gateway(content):
|
|
def factory(*args, **kwargs):
|
|
return _FakeGatewayClient(content, _GATEWAY_HEADERS)
|
|
|
|
return factory
|
|
_counter_issues_gitea = [0]
|
|
@pytest.fixture(autouse=True)
|
|
def _init_db_issues_gitea():
|
|
init_db()
|
|
yield
|
|
@pytest.fixture
|
|
def gitea_env():
|
|
fake = FakeGiteaClient()
|
|
runtime.set_client(fake)
|
|
set_setting("gitea_base_url", "https://gitea.test")
|
|
set_setting("gitea_owner", "retoor")
|
|
set_setting("gitea_repo", "pydevplace")
|
|
set_setting("gitea_token", "test-token")
|
|
set_setting("issue_ai_enhance", "0")
|
|
yield fake
|
|
runtime.set_client(None)
|
|
for table in ("issue_tickets", "issue_comment_authors"):
|
|
for row in list(get_table(table).find()):
|
|
get_table(table).delete(uid=row["uid"])
|
|
for row in list(get_table("jobs").find(kind="issue_create")):
|
|
get_table("jobs").delete(uid=row["uid"])
|
|
def _make_user_issues_gitea():
|
|
_counter_issues_gitea[0] += 1
|
|
uid = f"issuetest-user-{_counter_issues_gitea[0]}"
|
|
username = f"issuetester{_counter_issues_gitea[0]}"
|
|
get_table("users").upsert(
|
|
{
|
|
"uid": uid,
|
|
"username": username,
|
|
"terms_version": "1",
|
|
"role": "member",
|
|
"xp": 0,
|
|
"level": 1,
|
|
"is_active": True,
|
|
},
|
|
["uid"],
|
|
)
|
|
return uid, username
|
|
def _unread(user_uid):
|
|
return [
|
|
n
|
|
for n in get_table("notifications").find(user_uid=user_uid)
|
|
if n.get("type") == "issue"
|
|
]
|
|
def _drive_jobs():
|
|
async def run():
|
|
svc = IssueCreateService()
|
|
for _ in range(200):
|
|
await svc.run_once()
|
|
refresh_snapshot()
|
|
pending = [
|
|
r
|
|
for r in get_table("jobs").find(kind="issue_create")
|
|
if r["status"] in ("pending", "running")
|
|
]
|
|
if not pending and not svc._inflight:
|
|
return
|
|
await asyncio.sleep(0.02)
|
|
|
|
run_async(run())
|
|
def _role_user(role: str):
|
|
uid, username = _make_user_issues_gitea()
|
|
get_table("users").update({"uid": uid, "role": role}, ["uid"])
|
|
return uid, username
|
|
def _open_ticket(fake, role: str):
|
|
import devplacepy.main as m
|
|
from starlette.testclient import TestClient
|
|
from devplacepy.utils import create_session
|
|
|
|
uid, _ = _role_user(role)
|
|
issue = run_async(fake.create_issue("Submit does nothing", "details"))
|
|
number = int(issue["number"])
|
|
store.record_ticket(
|
|
number=number,
|
|
author_uid=uid,
|
|
original_title="Submit does nothing",
|
|
original_description="details",
|
|
enhanced_title="Submit does nothing",
|
|
html_url=issue.get("html_url", ""),
|
|
status="open",
|
|
)
|
|
client = TestClient(m.app)
|
|
client.cookies.set("session", create_session(uid))
|
|
return number, client
|
|
|
|
|
|
def test_fake_client_create_and_list(gitea_env):
|
|
async def go():
|
|
issue = await gitea_env.create_issue("Crash on save", "details")
|
|
assert issue["number"] == 1
|
|
assert issue["state"] == "open"
|
|
issues, total = await gitea_env.list_issues(state="open")
|
|
assert total == 1
|
|
assert issues[0]["number"] == 1
|
|
|
|
run_async(go())
|
|
|
|
|
|
def test_fake_client_state_and_comments(gitea_env):
|
|
async def go():
|
|
issue = await gitea_env.create_issue("t", "b")
|
|
number = issue["number"]
|
|
await gitea_env.create_comment(number, "first")
|
|
fetched = await gitea_env.get_issue(number)
|
|
assert fetched["comments"] == 1
|
|
closed = await gitea_env.set_state(number, "closed")
|
|
assert closed["state"] == "closed"
|
|
open_issues, _ = await gitea_env.list_issues(state="open")
|
|
assert open_issues == []
|
|
all_issues, total = await gitea_env.list_issues(state="all")
|
|
assert total == 1
|
|
|
|
run_async(go())
|
|
|
|
|
|
def test_store_records_and_maps(gitea_env):
|
|
uid, _ = _make_user_issues_gitea()
|
|
store.record_ticket(7, uid, "orig", "desc", "Better", "https://x/7", "open")
|
|
assert store.author_uid_for_issue(7) == uid
|
|
assert store.author_map([7, 8]) == {7: uid}
|
|
cid = store.record_comment_author(99, 7, uid)
|
|
assert cid
|
|
assert store.comment_author_map([99, 100]) == {99: uid}
|
|
assert store.local_comment_ids(7) == {99}
|
|
|
|
|
|
def test_enhance_fallback_when_disabled(gitea_env):
|
|
result = run_async(enhance_ticket("Title", "Body text", gitea_config()))
|
|
assert result.enhanced is False
|
|
assert "## Summary" in result.body
|
|
assert "## Steps to Reproduce" in result.body
|
|
assert result.title == "Title"
|
|
|
|
|
|
def test_create_job_files_issue(gitea_env):
|
|
uid, username = _make_user_issues_gitea()
|
|
job_uid = queue.enqueue(
|
|
"issue_create",
|
|
{"author_uid": uid, "title": "Login broken", "description": "cannot log in"},
|
|
"user",
|
|
uid,
|
|
"Login broken",
|
|
)
|
|
_drive_jobs()
|
|
job = queue.get_job(job_uid)
|
|
assert job["status"] == "done"
|
|
number = job["result"]["number"]
|
|
assert number == 1
|
|
ticket = store.get_ticket(number)
|
|
assert ticket["author_uid"] == uid
|
|
assert _unread(uid)
|
|
issue = run_async(gitea_env.get_issue(number))
|
|
assert username in issue["body"]
|
|
|
|
|
|
def test_create_job_fails_without_config(gitea_env):
|
|
set_setting("gitea_token", "")
|
|
uid, _ = _make_user_issues_gitea()
|
|
job_uid = queue.enqueue(
|
|
"issue_create",
|
|
{"author_uid": uid, "title": "x", "description": "y"},
|
|
"user",
|
|
uid,
|
|
"x",
|
|
)
|
|
_drive_jobs()
|
|
job = queue.get_job(job_uid)
|
|
assert job["status"] == "failed"
|
|
assert "not configured" in job["error"].lower()
|
|
|
|
|
|
def test_poller_notifies_on_developer_reply(gitea_env):
|
|
uid, _ = _make_user_issues_gitea()
|
|
issue = run_async(gitea_env.create_issue("issue", "body"))
|
|
number = issue["number"]
|
|
store.record_ticket(number, uid, "issue", "body", "issue", issue["html_url"], "open")
|
|
store.update_ticket_cache(number, "open", 0)
|
|
|
|
gitea_env.add_external_comment(number, "developer", "looking into it")
|
|
before = len(_unread(uid))
|
|
run_async(IssueTrackerService().run_once())
|
|
refresh_snapshot()
|
|
assert len(_unread(uid)) == before + 1
|
|
assert int(store.get_ticket(number)["last_comment_count"]) == 1
|
|
|
|
|
|
def test_poller_ignores_local_comment(gitea_env):
|
|
uid, _ = _make_user_issues_gitea()
|
|
issue = run_async(gitea_env.create_issue("issue", "body"))
|
|
number = issue["number"]
|
|
store.record_ticket(number, uid, "issue", "body", "issue", issue["html_url"], "open")
|
|
store.update_ticket_cache(number, "open", 0)
|
|
|
|
comment = run_async(gitea_env.create_comment(number, "me again"))
|
|
store.record_comment_author(comment["id"], number, uid)
|
|
before = len(_unread(uid))
|
|
run_async(IssueTrackerService().run_once())
|
|
refresh_snapshot()
|
|
assert len(_unread(uid)) == before
|
|
|
|
|
|
def test_poller_notifies_on_status_change(gitea_env):
|
|
uid, _ = _make_user_issues_gitea()
|
|
issue = run_async(gitea_env.create_issue("issue", "body"))
|
|
number = issue["number"]
|
|
store.record_ticket(number, uid, "issue", "body", "issue", issue["html_url"], "open")
|
|
store.update_ticket_cache(number, "open", 0)
|
|
|
|
run_async(gitea_env.set_state(number, "closed"))
|
|
before = len(_unread(uid))
|
|
run_async(IssueTrackerService().run_once())
|
|
refresh_snapshot()
|
|
assert len(_unread(uid)) == before + 1
|
|
assert store.get_ticket(number)["last_status"] == "closed"
|
|
|
|
|
|
def test_planning_empty_when_no_issues(gitea_env):
|
|
markdown, ai_used = run_async(generate_plan([], gitea_config()))
|
|
assert ai_used is False
|
|
assert markdown.startswith("# Open Tickets Implementation Plan")
|
|
assert "no open tickets" in markdown.lower()
|
|
|
|
|
|
def test_planning_fallback_is_phased_with_verbatim_body(gitea_env):
|
|
issues = [
|
|
{
|
|
"number": 41,
|
|
"title": "Broken functionality",
|
|
"labels": [{"name": "bug"}],
|
|
"body": "Something is broken.\nStep one fails.",
|
|
},
|
|
{
|
|
"number": 7,
|
|
"title": "Show costs",
|
|
"labels": [{"name": "feature"}],
|
|
"body": "Display research costs for admins.",
|
|
},
|
|
]
|
|
totals = new_usage_totals()
|
|
markdown, ai_used = run_async(generate_plan(issues, gitea_config(), totals))
|
|
assert ai_used is False
|
|
assert "## Execution Order" in markdown
|
|
assert "1. #41 - Broken functionality" in markdown
|
|
assert "## Phase 1: bug" in markdown
|
|
assert "## Phase 2: feature" in markdown
|
|
assert "### #41 Broken functionality" in markdown
|
|
assert "> Something is broken." in markdown
|
|
assert "> Step one fails." in markdown
|
|
assert totals["calls"] == 0
|
|
|
|
|
|
def test_planning_meters_usage_via_gateway(gitea_env, monkeypatch):
|
|
set_setting("issue_ai_enhance", "1")
|
|
refresh_snapshot()
|
|
monkeypatch.setattr(
|
|
"devplacepy.stealth.stealth_async_client",
|
|
_fake_gateway("# Open Tickets Implementation Plan\n\nDone."),
|
|
)
|
|
totals = new_usage_totals()
|
|
issues = [{"number": 1, "title": "t", "labels": [], "body": "b"}]
|
|
markdown, ai_used = run_async(generate_plan(issues, gitea_config(), totals))
|
|
assert ai_used is True
|
|
assert markdown.startswith("# Open Tickets Implementation Plan\n\nDone.")
|
|
assert "# Appendix: Source Tickets (verbatim)" in markdown
|
|
assert totals["calls"] == 1
|
|
assert totals["total_tokens"] == 600
|
|
|
|
|
|
def test_planning_ai_output_always_carries_verbatim_appendix(gitea_env, monkeypatch):
|
|
set_setting("issue_ai_enhance", "1")
|
|
refresh_snapshot()
|
|
monkeypatch.setattr(
|
|
"devplacepy.stealth.stealth_async_client",
|
|
_fake_gateway("# Open Tickets Implementation Plan\n\nSummarised plan only."),
|
|
)
|
|
issues = [
|
|
{
|
|
"number": 12,
|
|
"title": "Add export",
|
|
"labels": [{"name": "feature"}],
|
|
"html_url": "https://gitea.test/retoor/pydevplace/issues/12",
|
|
"body": "Full body line A.\nFull body line B.",
|
|
}
|
|
]
|
|
markdown, ai_used = run_async(generate_plan(issues, gitea_config()))
|
|
assert ai_used is True
|
|
assert "Summarised plan only." in markdown
|
|
assert "# Appendix: Source Tickets (verbatim)" in markdown
|
|
assert "## #12 Add export" in markdown
|
|
assert "https://gitea.test/retoor/pydevplace/issues/12" in markdown
|
|
assert "> Full body line A." in markdown
|
|
assert "> Full body line B." in markdown
|
|
|
|
|
|
def test_verbatim_tickets_reproduces_full_body_and_labels():
|
|
issues = [
|
|
{
|
|
"number": 5,
|
|
"title": "Fix crash",
|
|
"labels": [{"name": "bug"}, {"name": "urgent"}],
|
|
"html_url": "https://gitea.test/x/5",
|
|
"body": "Steps:\n1. open\n2. boom",
|
|
}
|
|
]
|
|
markdown = verbatim_tickets(issues)
|
|
assert markdown.startswith("# Appendix: Source Tickets (verbatim)")
|
|
assert "## #5 Fix crash" in markdown
|
|
assert "**Labels**: bug, urgent" in markdown
|
|
assert "**Source**: https://gitea.test/x/5" in markdown
|
|
assert "> Steps:" in markdown
|
|
assert "> 1. open" in markdown
|
|
assert "> 2. boom" in markdown
|
|
|
|
|
|
def test_collect_open_issues_pages_through_fake(gitea_env):
|
|
for index in range(3):
|
|
run_async(gitea_env.create_issue(f"Ticket {index}", f"Body {index}"))
|
|
issues = run_async(collect_open_issues(gitea_env))
|
|
assert {issue["number"] for issue in issues} == {1, 2, 3}
|
|
|
|
|
|
def test_planning_service_filters_to_selected_numbers(gitea_env):
|
|
for index in range(3):
|
|
run_async(gitea_env.create_issue(f"Ticket {index}", f"Body {index}"))
|
|
service = PlanningReportService()
|
|
|
|
selected = run_async(service._collect([3, 1]))
|
|
assert [issue["number"] for issue in selected] == [3, 1]
|
|
|
|
missing = run_async(service._collect([99]))
|
|
assert missing == []
|
|
|
|
everything = run_async(service._collect([]))
|
|
assert {issue["number"] for issue in everything} == {1, 2, 3}
|
|
|
|
|
|
def test_planning_service_parses_payload_numbers():
|
|
service = PlanningReportService()
|
|
job = {"payload": {"numbers": ["4", 7, "x", 7, None]}}
|
|
assert service._selected_numbers(job) == [4, 7]
|
|
assert service._selected_numbers({"payload": {}}) == []
|
|
|
|
|
|
def test_enhance_meters_usage_via_gateway(gitea_env, monkeypatch):
|
|
set_setting("issue_ai_enhance", "1")
|
|
refresh_snapshot()
|
|
monkeypatch.setattr(
|
|
"devplacepy.stealth.stealth_async_client",
|
|
_fake_gateway('{"title": "Fix login", "body": "## Summary\\nFix it."}'),
|
|
)
|
|
totals = new_usage_totals()
|
|
result = run_async(enhance_ticket("login", "broken", gitea_config(), totals))
|
|
assert result.enhanced is True
|
|
assert result.title == "Fix login"
|
|
assert totals["calls"] == 1
|
|
assert abs(totals["cost_usd"] - 0.0001) < 1e-9
|
|
|
|
|
|
def test_enhance_fallback_does_not_meter(gitea_env):
|
|
totals = new_usage_totals()
|
|
result = run_async(enhance_ticket("Title", "Body", gitea_config(), totals))
|
|
assert result.enhanced is False
|
|
assert totals["calls"] == 0
|
|
|
|
|
|
def test_create_job_meters_usage(gitea_env, monkeypatch):
|
|
get_table("issue_usage").delete()
|
|
set_setting("issue_ai_enhance", "1")
|
|
refresh_snapshot()
|
|
monkeypatch.setattr(
|
|
"devplacepy.stealth.stealth_async_client",
|
|
_fake_gateway('{"title": "Login broken", "body": "## Summary\\nbroken."}'),
|
|
)
|
|
uid, _ = _make_user_issues_gitea()
|
|
job_uid = queue.enqueue(
|
|
"issue_create",
|
|
{"author_uid": uid, "title": "login", "description": "cannot log in"},
|
|
"user",
|
|
uid,
|
|
"login",
|
|
)
|
|
_drive_jobs()
|
|
assert queue.get_job(job_uid)["status"] == "done"
|
|
assert get_issue_usage()["calls"] == 1
|
|
|
|
|
|
def test_issue_tracker_collect_metrics(gitea_env):
|
|
get_table("issue_usage").delete()
|
|
add_issue_usage(
|
|
{
|
|
"calls": 3,
|
|
"prompt_tokens": 900,
|
|
"completion_tokens": 300,
|
|
"total_tokens": 1200,
|
|
"cost_usd": 0.003,
|
|
"upstream_latency_ms": 1200.0,
|
|
"total_latency_ms": 1500.0,
|
|
}
|
|
)
|
|
metrics = IssueTrackerService().collect_metrics()
|
|
by_label = {card["label"]: card["value"] for card in metrics["stats"]}
|
|
assert by_label["AI calls"] == 3
|
|
assert by_label["Total tokens"] == 1200
|
|
assert by_label["Total cost"] == "$0.0030"
|