PMFlow/backend/tests/test_feedback_center_api.py

354 lines
13 KiB
Python

from __future__ import annotations
import os
from datetime import datetime
from types import SimpleNamespace
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy import JSON
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
os.environ["DATABASE_URL"] = "postgresql+asyncpg://tester:tester@localhost:5432/pmflow_test"
from app.api.ai_steward_api import routes_feedback_center
from app.core.database import get_db
from app.dependencies.auth import get_current_user
from app.dependencies.tenant import get_tenant_id
from app.models.base import Base
from app.models.feedback_center import FeedbackCenterRecord, FeedbackCenterRecordRevision
import app.services.feedback_center.service as feedback_center_service_module
def _use_sqlite_json_types() -> None:
for table in (FeedbackCenterRecord.__table__, FeedbackCenterRecordRevision.__table__):
for column in table.columns:
if column.name in {"before_value", "after_value", "ai_snapshot", "reason_candidates", "before_snapshot", "after_snapshot"}:
column.type = JSON()
@pytest_asyncio.fixture
async def db_session():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
_use_sqlite_json_types()
async with engine.begin() as conn:
await conn.run_sync(
lambda sync_conn: Base.metadata.create_all(
sync_conn,
tables=[
FeedbackCenterRecord.__table__,
FeedbackCenterRecordRevision.__table__,
],
)
)
maker = async_sessionmaker(engine, expire_on_commit=False)
async with maker() as session:
yield session
await engine.dispose()
def _build_app(db_session):
app = FastAPI()
app.include_router(routes_feedback_center.router)
async def _fake_get_db():
yield db_session
app.dependency_overrides[get_db] = _fake_get_db
app.dependency_overrides[get_tenant_id] = lambda: 1
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
id=9001,
tenant_id=1,
name="管理员",
username="admin",
role="ADMIN",
)
return app
@pytest.mark.asyncio
async def test_feedback_center_summary_list_detail_and_catalog(db_session) -> None:
db_session.add_all(
[
FeedbackCenterRecord(
tenant_id=1,
project_id=11,
source_type="task_breakdown",
source_id=101,
object_type="task",
object_id=201,
field_name="task_object",
source_title="任务拆解",
object_title="任务拆解",
before_value=None,
after_value={"title": "任务拆解"},
ai_confidence=0.91,
reason_candidates=[{"code": "manual_override", "label": "人工覆盖"}],
reason_code="manual_override",
review_status="pending",
auto_assigned=False,
),
FeedbackCenterRecord(
tenant_id=1,
project_id=11,
source_type="qa_feedback",
source_id=102,
object_type="qa_record",
object_id=202,
field_name="qa_feedback",
source_title="Q&A",
object_title="Q&A",
before_value={"answer": "旧答案"},
after_value={"rating": "negative", "correct_answer": "新答案"},
ai_confidence=None,
reason_candidates=[{"code": "answer_incorrect", "label": "答案不正确"}],
reason_code="answer_incorrect",
review_status="reviewed",
auto_assigned=False,
),
FeedbackCenterRecord(
tenant_id=1,
project_id=22,
source_type="signal_feedback",
source_id=103,
object_type="signal",
object_id=203,
field_name="strategic_classification",
source_title="上线异常告警",
object_title="上线异常告警",
before_value={"ai_predicted_strategic": True},
after_value={"was_strategic": False},
ai_confidence=0.8,
reason_candidates=[{"code": "strategic_judgement_mismatch", "label": "战略判断偏差"}],
reason_code="strategic_judgement_mismatch",
review_status="accepted",
auto_assigned=False,
),
]
)
await db_session.commit()
app = _build_app(db_session)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
summary_resp = await client.get("/ai-steward/feedback-center/summary", params={"project_id": 11})
list_resp = await client.get("/ai-steward/feedback-center/items", params={"project_id": 11, "limit": 20})
detail_resp = await client.get("/ai-steward/feedback-center/items/1", params={"project_id": 11})
catalog_resp = await client.get(
"/ai-steward/feedback-center/reason-catalog",
params={"source_type": "task_override", "field_name": "priority"},
)
assert summary_resp.status_code == 200
summary_payload = summary_resp.json()
assert summary_payload["code"] == 0
assert summary_payload["data"]["total"] == 2
assert summary_payload["data"]["by_status"]["pending"] == 1
assert summary_payload["data"]["by_status"]["reviewed"] == 1
assert list_resp.status_code == 200
list_payload = list_resp.json()
assert list_payload["code"] == 0
assert len(list_payload["data"]["items"]) == 2
assert all(item["project_id"] == 11 for item in list_payload["data"]["items"])
assert detail_resp.status_code == 200
detail_payload = detail_resp.json()
assert detail_payload["code"] == 0
assert detail_payload["data"]["record"]["id"] == 1
assert detail_payload["data"]["record"]["source_type"] == "task_breakdown"
assert detail_payload["data"]["revisions"] == []
assert catalog_resp.status_code == 200
catalog_payload = catalog_resp.json()
assert catalog_payload["code"] == 0
codes = [item["code"] for item in catalog_payload["data"]["items"]]
assert "priority_misalignment" in codes
@pytest.mark.asyncio
async def test_feedback_center_summary_honors_window_and_scope_query_params(db_session, monkeypatch) -> None:
fixed_now = datetime(2025, 1, 15, 12, 0, 0)
monkeypatch.setattr(feedback_center_service_module, "now_cn_naive", lambda: fixed_now)
db_session.add_all(
[
FeedbackCenterRecord(
tenant_id=1,
project_id=11,
source_type="task_breakdown",
source_id=801,
object_type="task",
object_id=901,
field_name="task_breakdown",
source_title="任务拆解",
object_title="任务拆解",
review_status="accepted",
auto_assigned=True,
created_at=datetime(2025, 1, 15, 8, 0, 0),
reviewed_at=datetime(2025, 1, 15, 8, 30, 0),
),
FeedbackCenterRecord(
tenant_id=1,
project_id=11,
source_type="task_override",
source_id=802,
object_type="task",
object_id=901,
field_name="priority",
source_title="优先级修正",
object_title="优先级修正",
review_status="reviewed",
created_at=datetime(2025, 1, 14, 8, 0, 0),
reviewed_at=datetime(2025, 1, 14, 8, 50, 0),
),
FeedbackCenterRecord(
tenant_id=1,
project_id=11,
source_type="qa_feedback",
source_id=803,
object_type="qa_record",
object_id=902,
field_name="qa_feedback",
source_title="QA 反馈",
object_title="QA 反馈",
review_status="accepted",
created_at=datetime(2025, 1, 13, 8, 0, 0),
reviewed_at=datetime(2025, 1, 13, 8, 20, 0),
),
]
)
await db_session.commit()
app = _build_app(db_session)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/ai-steward/feedback-center/summary",
params={"project_id": 11, "window_days": 30, "source_group": "task"},
)
assert response.status_code == 200
payload = response.json()
assert payload["code"] == 0
assert payload["data"]["source_group"] == "task"
assert payload["data"]["trend"]["window_days"] == 30
assert payload["data"]["trend"]["current"]["total_records"] == 2
assert payload["data"]["trend"]["current"]["task_breakdown_count"] == 1
assert payload["data"]["trend"]["current"]["task_override_count"] == 1
assert payload["data"]["trend"]["current"]["qa_feedback_count"] == 0
@pytest.mark.asyncio
async def test_feedback_center_summary_accepts_scope_params(db_session) -> None:
db_session.add_all(
[
FeedbackCenterRecord(
tenant_id=1,
project_id=11,
source_type="task_breakdown",
source_id=201,
object_type="task",
object_id=301,
field_name="task_object",
review_status="pending",
),
FeedbackCenterRecord(
tenant_id=1,
project_id=11,
source_type="qa_feedback",
source_id=202,
object_type="qa_record",
object_id=302,
field_name="qa_feedback",
review_status="accepted",
),
]
)
await db_session.commit()
app = _build_app(db_session)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
scoped_resp = await client.get(
"/ai-steward/feedback-center/summary",
params={"project_id": 11, "window_days": 30, "source_group": "qa"},
)
invalid_window = await client.get(
"/ai-steward/feedback-center/summary",
params={"project_id": 11, "window_days": 14},
)
invalid_group = await client.get(
"/ai-steward/feedback-center/summary",
params={"project_id": 11, "source_group": "unknown"},
)
assert scoped_resp.status_code == 200
scoped_payload = scoped_resp.json()
assert scoped_payload["code"] == 0
assert scoped_payload["data"]["trend"]["window_days"] == 30
assert scoped_payload["data"]["by_source_type"] == {"qa_feedback": 1}
assert invalid_window.status_code == 400
assert invalid_group.status_code == 400
@pytest.mark.asyncio
async def test_feedback_center_review_updates_record_and_revision(db_session) -> None:
db_session.add(
FeedbackCenterRecord(
tenant_id=1,
project_id=11,
source_type="task_override",
source_id=500,
object_type="task",
object_id=600,
field_name="priority",
source_title="优先级修正",
object_title="优先级修正",
before_value="P2",
after_value="P1",
ai_confidence=0.62,
reason_candidates=[{"code": "priority_misalignment", "label": "优先级错配"}],
reason_code="priority_misalignment",
review_status="pending",
auto_assigned=False,
)
)
await db_session.commit()
app = _build_app(db_session)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/ai-steward/feedback-center/items/1/review",
json={
"review_status": "accepted",
"reason_code": "priority_misalignment",
"reason_text": "优先级确认需要上调",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["code"] == 0
assert payload["data"]["review_status"] == "accepted"
assert payload["data"]["reason_code"] == "priority_misalignment"
assert payload["data"]["reviewed_by"] == 9001
refreshed = await db_session.get(FeedbackCenterRecord, 1)
assert refreshed is not None
assert refreshed.review_status == "accepted"
assert refreshed.reason_text == "优先级确认需要上调"
revisions = (
await db_session.execute(
FeedbackCenterRecordRevision.__table__.select().where(FeedbackCenterRecordRevision.record_id == 1)
)
).mappings().all()
assert len(revisions) == 1
assert revisions[0]["before_snapshot"]["review_status"] == "pending"
assert revisions[0]["after_snapshot"]["review_status"] == "accepted"