mirror of
https://github.com/superdesigndev/treg.git
synced 2026-10-02 03:24:35 +08:00
316 lines
14 KiB
Python
316 lines
14 KiB
Python
"""Feedback reaches durable storage through HTTP, CLI and both MCP surfaces."""
|
|
|
|
import io
|
|
import json
|
|
|
|
import httpx
|
|
import pytest
|
|
from sqlmodel import select
|
|
|
|
from treg import cli
|
|
from treg.application import feedback as feedback_app
|
|
from treg.config import get_settings
|
|
from treg.feedback_contract import FEEDBACK_CATEGORIES
|
|
from treg.infra.db import session_maker
|
|
from treg.models import CallRecord, Feedback, FeedbackHandling, FeedbackHandlingEvent, LedgerEntry
|
|
|
|
|
|
async def rows():
|
|
async with session_maker() as db:
|
|
return list((await db.execute(select(Feedback).order_by(Feedback.id))).scalars())
|
|
|
|
|
|
async def test_feedback_is_durable_and_private_to_the_team(clients):
|
|
response = await clients.post("/feedback", json={
|
|
"category": "friction", "message": " Pagination is unclear. ",
|
|
})
|
|
assert response.status_code == 201, response.text
|
|
receipt = response.json()
|
|
assert receipt["status"] == "received"
|
|
report = (await clients.get(f'/feedback/{receipt["feedback_id"]}')).json()
|
|
assert report["message"] == "Pagination is unclear."
|
|
assert report["call_ids"] == []
|
|
(row,) = await rows()
|
|
assert row.id == receipt["feedback_id"]
|
|
assert row.user_email == "tim@superdesign.dev"
|
|
|
|
other = (await clients.post("/users", json={"email": "other-feedback@example.test"})).json()
|
|
denied = await clients.get(f"/feedback/{row.id}", headers={"X-Treg-Token": other["token"]})
|
|
assert denied.status_code == 404
|
|
assert (await clients.get("/admin/feedback")).status_code == 403
|
|
assert (await clients.post("/feedback", headers={"X-Treg-Token": ""}, json={
|
|
"category": "other", "message": "Anonymous",
|
|
})).status_code == 401
|
|
|
|
|
|
@pytest.mark.parametrize("body", [
|
|
{"category": "rating", "message": "Five stars"},
|
|
{"category": "other", "message": " "},
|
|
{"category": "other", "message": "x" * 2001},
|
|
{"category": "other", "message": "Example", "raw_response": {"private": "data"}},
|
|
{"category": "other", "message": "Example", "call_ids": ["person@example.test"]},
|
|
{"category": "other", "message": "Example", "call_ids": ["id"] * 101},
|
|
{"category": "other", "message": "Example", "endpoint_id": "https://private.example/path"},
|
|
])
|
|
async def test_invalid_feedback_is_not_stored(clients, body):
|
|
assert (await clients.post("/feedback", json=body)).status_code == 422
|
|
assert await rows() == []
|
|
|
|
|
|
async def test_call_references_verify_only_same_team_without_requiring_audit(clients):
|
|
own = (await clients.post("/orgs", json={"name": "Feedback source"})).json()
|
|
other = (await clients.post("/orgs", json={"name": "Other source"})).json()
|
|
async with session_maker() as db:
|
|
for org, ref in [(own, "same-team"), (other, "other-team")]:
|
|
db.add(CallRecord(org_id=org["org_id"], user_email="tim@superdesign.dev",
|
|
tool_name="example", method="GET", path="/", status_code=200,
|
|
call_ref=ref))
|
|
db.add(LedgerEntry(id="feedback-ledger-fixture", org_id=own["org_id"],
|
|
kind="settle", amount_micro=0, call_id="ledger-only"))
|
|
await db.commit()
|
|
response = await clients.post("/feedback", headers={"X-Treg-Token": own["token"]}, json={
|
|
"category": "quality", "message": "Some results are outdated.",
|
|
"call_ids": ["same-team", "other-team", "missing", "ledger-only", "same-team"],
|
|
})
|
|
assert response.status_code == 201, response.text
|
|
(row,) = await rows()
|
|
assert row.call_ids == ["same-team", "other-team", "missing", "ledger-only"]
|
|
assert row.verified_call_ids == ["same-team", "ledger-only"]
|
|
|
|
|
|
async def test_rate_limit_is_per_team(clients, monkeypatch):
|
|
monkeypatch.setattr(feedback_app, "RATE_MAX", 1)
|
|
body = {"category": "other", "message": "A suggestion."}
|
|
assert (await clients.post("/feedback", json=body)).status_code == 201
|
|
assert (await clients.post("/feedback", json=body)).status_code == 429
|
|
other = (await clients.post("/orgs", json={"name": "Separate feedback budget"})).json()
|
|
assert (await clients.post("/feedback", json=body,
|
|
headers={"X-Treg-Token": other["token"]})).status_code == 201
|
|
assert len(await rows()) == 2
|
|
|
|
|
|
async def test_failed_insert_rolls_back_without_an_acknowledgement(clients, monkeypatch):
|
|
from treg.domain import feedback
|
|
|
|
def fail(*args, **kwargs):
|
|
raise RuntimeError("storage unavailable")
|
|
|
|
monkeypatch.setattr(feedback_app, "RATE_MAX", 1)
|
|
with monkeypatch.context() as patch:
|
|
patch.setattr(feedback, "add", fail)
|
|
with pytest.raises(RuntimeError, match="storage unavailable"):
|
|
await clients.post("/feedback", json={"category": "other", "message": "Example"})
|
|
assert await rows() == []
|
|
assert (await clients.post("/feedback", json={
|
|
"category": "other", "message": "Retry after recovery",
|
|
})).status_code == 201
|
|
|
|
|
|
async def test_admin_can_filter_and_page_reports(clients, monkeypatch):
|
|
monkeypatch.setenv("TREG_ADMIN_TOKEN", "feedback-admin-test-token")
|
|
get_settings.cache_clear()
|
|
try:
|
|
for category in ["quality", "friction", "quality"]:
|
|
await clients.post("/feedback", json={"category": category, "message": "Example"})
|
|
headers = {"X-Treg-Token": "feedback-admin-test-token"}
|
|
first = (await clients.get("/admin/feedback", headers=headers,
|
|
params={"category": "quality", "limit": 1})).json()
|
|
second = (await clients.get("/admin/feedback", headers=headers, params={
|
|
"category": "quality", "limit": 1, "before": first["next_before"],
|
|
})).json()
|
|
assert first["items"][0]["id"] > second["items"][0]["id"]
|
|
assert first["items"][0]["category"] == second["items"][0]["category"] == "quality"
|
|
finally:
|
|
get_settings.cache_clear()
|
|
|
|
|
|
def test_cli_feedback_sends_only_the_declared_fields(monkeypatch, capsys):
|
|
captured = []
|
|
|
|
def handle(request):
|
|
captured.append(request)
|
|
return httpx.Response(201, json={"feedback_id": 7, "status": "received"})
|
|
|
|
monkeypatch.setattr(cli, "_client", lambda cfg: httpx.Client(
|
|
transport=httpx.MockTransport(handle), base_url=cfg["base_url"],
|
|
))
|
|
monkeypatch.setattr("sys.stdin", io.StringIO("A sanitized suggestion.\n"))
|
|
args = cli.build_parser().parse_args([
|
|
"feedback", "submit", "other", "-", "--call-id", "first", "--call-id", "second",
|
|
])
|
|
args.fn(args, {"base_url": "https://self-hosted.example.test"})
|
|
assert str(captured[0].url) == "https://self-hosted.example.test/feedback"
|
|
assert json.loads(captured[0].content) == {
|
|
"category": "other", "message": "A sanitized suggestion.\n", "call_ids": ["first", "second"],
|
|
}
|
|
assert json.loads(capsys.readouterr().out)["feedback_id"] == 7
|
|
|
|
|
|
@pytest.mark.parametrize("surface", ["team", "directory"])
|
|
async def test_both_mcp_surfaces_submit_to_the_same_intake(clients, surface):
|
|
from test_mcp import _call_tool as team_call, mcp_session
|
|
from test_mcp_directory import _call_tool as directory_call, directory_session
|
|
|
|
token = clients.headers["X-Treg-Token"]
|
|
context = mcp_session(clients) if surface == "team" else directory_session()
|
|
call = team_call if surface == "team" else directory_call
|
|
async with context as client:
|
|
result = await call(client, "feedback", {
|
|
"category": "pricing", "message": "The price unit is unclear.",
|
|
}, token=token)
|
|
assert result["status"] == "received", result
|
|
(row,) = await rows()
|
|
assert row.id == result["feedback_id"]
|
|
assert row.category == "pricing"
|
|
|
|
|
|
async def test_mcp_exposes_the_small_category_enum():
|
|
from treg.mcp import mcp, directory_mcp
|
|
|
|
for server in [mcp, directory_mcp]:
|
|
tool = next(tool for tool in await server.list_tools() if tool.name == "feedback")
|
|
properties = tool.input_schema["properties"]
|
|
assert properties["category"]["enum"] == list(FEEDBACK_CATEGORIES)
|
|
assert tool.input_schema["required"] == ["category", "message"]
|
|
assert tool.annotations.read_only_hint is False
|
|
assert tool.annotations.destructive_hint is False
|
|
assert tool.annotations.open_world_hint is False
|
|
|
|
|
|
async def test_deleting_a_team_removes_its_feedback(clients):
|
|
org = (await clients.post("/orgs", json={"name": "Feedback cleanup"})).json()
|
|
response = await clients.post("/feedback", headers={"X-Treg-Token": org["token"]}, json={
|
|
"category": "other", "message": "A team-scoped suggestion.",
|
|
})
|
|
assert response.status_code == 201, response.text
|
|
assert len(await rows()) == 1
|
|
slug = next(item["slug"] for item in (await clients.get("/orgs")).json()
|
|
if item["org_id"] == org["org_id"])
|
|
deleted = await clients.delete(f'/orgs/{org["org_id"]}', params={"confirm": slug},
|
|
headers={"X-Treg-Token": org["token"]})
|
|
assert deleted.status_code == 200, deleted.text
|
|
assert await rows() == []
|
|
|
|
|
|
async def test_public_demo_token_cannot_submit_feedback(clients):
|
|
from test_public_demo import _mint_public, _org_with_stripe_tool
|
|
|
|
org_id = await _org_with_stripe_tool(clients)
|
|
token = await _mint_public(clients, org_id)
|
|
response = await clients.post("/feedback", headers={"X-Treg-Token": token}, json={
|
|
"category": "other", "message": "A report from a public token.",
|
|
})
|
|
assert response.status_code == 403, response.text
|
|
assert await rows() == []
|
|
|
|
|
|
def test_cli_feedback_get_uses_the_configured_registry(monkeypatch, capsys):
|
|
captured = []
|
|
|
|
def handle(request):
|
|
captured.append(request)
|
|
return httpx.Response(200, json={"feedback_id": 7, "status": "received", "message": "Example"})
|
|
|
|
monkeypatch.setattr(cli, "_client", lambda cfg: httpx.Client(
|
|
transport=httpx.MockTransport(handle), base_url=cfg["base_url"],
|
|
))
|
|
args = cli.build_parser().parse_args(["feedback", "get", "7"])
|
|
args.fn(args, {"base_url": "https://self-hosted.example.test"})
|
|
assert captured[0].method == "GET"
|
|
assert str(captured[0].url) == "https://self-hosted.example.test/feedback/7"
|
|
assert json.loads(capsys.readouterr().out)["message"] == "Example"
|
|
|
|
|
|
@pytest.mark.parametrize("status,code,hint", [
|
|
(401, "authentication_required", "treg login"),
|
|
(403, "access_denied", "active team"),
|
|
(404, "not_found", "ID"),
|
|
(422, "invalid_feedback", "submit --help"),
|
|
(429, "rate_limited", "later"),
|
|
(500, "submission_unconfirmed", "whether feedback was saved"),
|
|
])
|
|
def test_cli_feedback_errors_are_actionable_without_echoing_input(monkeypatch, capsys, status, code, hint):
|
|
private = "private-person@example.test"
|
|
monkeypatch.setattr(cli, "_client", lambda cfg: httpx.Client(
|
|
transport=httpx.MockTransport(lambda request: httpx.Response(status, json={
|
|
"detail": [{"input": private, "msg": private}],
|
|
})), base_url="https://registry.example.test",
|
|
))
|
|
args = cli.build_parser().parse_args(["feedback", "submit", "other", "A sanitized report."])
|
|
with pytest.raises(SystemExit) as exc:
|
|
args.fn(args, {})
|
|
assert exc.value.code == 1
|
|
output = capsys.readouterr()
|
|
body = json.loads(output.out)
|
|
assert body["error"] == code
|
|
assert hint in body["message"]
|
|
assert private not in output.out + output.err
|
|
|
|
|
|
@pytest.mark.parametrize("message,length", [(" ", 0), ("x" * 2001, 2001)])
|
|
def test_cli_feedback_validates_message_before_sending(monkeypatch, capsys, message, length):
|
|
monkeypatch.setattr(cli, "_client", lambda cfg: pytest.fail("must not send invalid feedback"))
|
|
args = cli.build_parser().parse_args(["feedback", "submit", "other", message])
|
|
with pytest.raises(SystemExit):
|
|
args.fn(args, {})
|
|
body = json.loads(capsys.readouterr().out)
|
|
assert body["error"] == "invalid_message"
|
|
assert body["actual_length"] == length
|
|
assert body["max_length"] == 2000
|
|
|
|
|
|
@pytest.mark.parametrize("action,code", [("submit", "submission_unconfirmed"), ("get", "request_failed")])
|
|
def test_cli_feedback_network_errors_do_not_claim_a_submission_failed(monkeypatch, capsys, action, code):
|
|
calls = []
|
|
|
|
def handle(request):
|
|
calls.append(request)
|
|
raise httpx.ReadTimeout("private transport details", request=request)
|
|
|
|
monkeypatch.setattr(cli, "_client", lambda cfg: httpx.Client(
|
|
transport=httpx.MockTransport(handle), base_url="https://registry.example.test",
|
|
))
|
|
tail = ["other", "Example"] if action == "submit" else ["7"]
|
|
args = cli.build_parser().parse_args(["feedback", action, *tail])
|
|
with pytest.raises(SystemExit):
|
|
args.fn(args, {})
|
|
output = capsys.readouterr()
|
|
assert json.loads(output.out)["error"] == code
|
|
assert "private transport details" not in output.out + output.err
|
|
assert len(calls) == 1
|
|
|
|
|
|
def test_cli_feedback_keeps_category_first_shorthand(monkeypatch):
|
|
calls = []
|
|
monkeypatch.setattr(cli, "_load_config", lambda: {})
|
|
monkeypatch.setattr(cli, "cmd_feedback", lambda args, cfg: calls.append((args.category, args.message)))
|
|
cli.main(["feedback", "quality", "Example"])
|
|
assert calls == [("quality", "Example")]
|
|
|
|
|
|
def test_cli_feedback_stdin_never_waits_for_interactive_input(monkeypatch, capsys):
|
|
monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: True)
|
|
args = cli.build_parser().parse_args(["feedback", "submit", "other", "-"])
|
|
with pytest.raises(SystemExit):
|
|
args.fn(args, {})
|
|
assert json.loads(capsys.readouterr().out)["error"] == "stdin_required"
|
|
|
|
|
|
async def test_internal_handling_does_not_change_public_receipt(clients):
|
|
receipt = (await clients.post("/feedback", json={
|
|
"category": "other", "message": "Synthetic report",
|
|
})).json()
|
|
report_id = receipt["feedback_id"]
|
|
before = (await clients.get(f"/feedback/{report_id}")).json()
|
|
async with session_maker() as db:
|
|
db.add(FeedbackHandling(feedback_id=report_id, status="resolved", version=1))
|
|
db.add(FeedbackHandlingEvent(
|
|
id="synthetic-internal-event", feedback_id=report_id, version=1,
|
|
from_status="open", to_status="resolved", note="Internal result only",
|
|
actor="shared-admin", source="api", links=["https://example.test/pull/1"],
|
|
))
|
|
await db.commit()
|
|
response = await clients.get(f"/feedback/{report_id}")
|
|
assert response.status_code == 200
|
|
assert response.json() == before
|