Files
geMoldInsight/tests/test_experience_feedback_router.py
cjw 2c9ba9d6b3 D17 批 1:Human-in-Loop 老师傅经验反馈(数据 + 权限 + 写入 API)
新增 experience_feedback 表(32 表迁移,alembic head b7d1f4a92c3e),
老师傅对系统推荐方案给出"采纳 / 调整 / 拒绝"反馈,按"产品指纹 +
工艺参数"为键跨任务匹配,下次同指纹产品分析自动消费。

变更内容:
- src/moldinsight/models/experience_feedback.py(new)ORM:Base 单点来源、
  跨模块裸 FK(user_id / processing_task_id / stp_file_id)、不建 ORM
  relationship;fingerprint JSON 列存跨任务匹配键
- src/moldinsight/models/__init__.py 导出 ExperienceFeedback
- migrations/versions/b7d1f4a92c3e_add_experience_feedback.py(new)32 表
  迁移;fingerprint 列 PG 下加 GIN 索引(jsonb_path_query 支持)
- src/shared/database/init_db.py 加 3 个权限码(view_experience_feedback /
  feedback_experience_hint / manage_experience_feedback)+ 新角色
  process_engineer;admin 角色 permissions 同步补齐;init_permissions /
  init_roles 改为按 code 比对(新增保留已有 id,避免 FK 引用失效)——
  修复既有 DB 启动期漏掉新权限的幂等 bug
- src/moldinsight/services/experience_feedback_service.py(new)service:
  compute_fingerprint 分桶(bbox_aspect / volume_bucket / face_bucket /
  undercut_class / material_family / is_foam)/ record_feedback(D9 边界:
  service.flush + 路由 commit;D17 衰减:同 stp_file_id 整体续期 90 天 TTL,
  无 celery beat 依赖)/ list_hints_for_task / resolve_for_process_params
- src/moldinsight/api/experience_feedback_router.py(new)路由:Pydantic
  模型写在路由文件内(项目硬规则);POST /api/tasks/{task_id}/experience-feedback
  + GET /api/tasks/{task_id}/experience-hints;归属 TaskQueryService.ensure_task_access
  + User.has_permission 全仓首次调用点
- src/moldinsight/api/__init__.py ROUTE_MODULES 注册新路由
- tests/test_model_ownership.py EXPECTED_TABLES 加 experience_feedback
  (31→32)
- tests/test_experience_feedback_fingerprint.py(new)分桶参数化覆盖
  bbox / volume / face / undercut / material / is_foam 各边界值
- tests/test_experience_feedback_router.py(new)API 契约 9 例
  (401/403/422/200 路径 + 衰减续期 + 任务归属校验 + ORM 注册收口)
- docs/STATUS.md 顶部加 2026-09-23 批 1 日志条目
- docs/TECH_DEBT.md D17 加批 1 已完成描述 + 剩余工作清单
- docs/API_CONTRACT.md §3.2 加 D17 端点表格

测试基线:185 passed, 9 skipped(净增 59 测试)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 16:11:17 +08:00

319 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""D17 Human-in-Loop 闭环:API 契约测试。
覆盖:
- POST /api/tasks/{task_id}/experience-feedback
- 401(无登录态 —— 由 Depends(get_current_active_user) 处理)
- 403(user 角色无 feedback_experience_hint 权限)
- 200(admin 角色有 manage_experience_feedback 全权限)
- 200(process_engineer 角色有 feedback_experience_hint 权限)
- GET /api/tasks/{task_id}/experience-hints
- 200 命中(同 stp_file_id 历史反馈聚合)
- cache invalidation(POST 写完后视图失效)
- D9 边界:record_feedback 失败时 db 不留半成品
"""
import pytest
from fastapi import FastAPI
from httpx import AsyncClient, ASGITransport
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import configure_mappers
from shared.models.base import Base
from shared.models.identity import User, Role, Permission, RolePermission, UserRole
from shared.services.auth_service import get_current_active_user
from shared.database.database import get_db_session
from moldinsight.api.experience_feedback_router import router as feedback_router
from moldinsight.models import (
STPFile, GeometryData, MoldCavityData, ProcessingTask, ExperienceFeedback,
)
@pytest.fixture
async def feedback_client(async_engine, seeded_db):
"""构造带 experience_feedback_router 的 test app。
与 conftest.client 不同,这里我们用 seeded_db 的 user=tester,但通过依赖覆盖
让所有请求都以 admin 身份进(admin 是项目测试约定身份)。
关键点:override 返回的 User 必须用 selectinload 预加载 user_roles → role → role_permissions → permission,
否则 User.has_permission() 内部访问 self.roles 触发跨 session lazy load 失败。
"""
from sqlalchemy.orm import selectinload
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
test_app = FastAPI()
test_app.include_router(feedback_router)
async def override_get_db_session():
async with session_factory() as session:
yield session
async def override_get_current_active_user():
async with session_factory() as session:
result = await session.execute(
select(User)
.where(User.username == "tester")
.options(
selectinload(User.user_roles)
.selectinload(UserRole.role)
.selectinload(Role.role_permissions)
.selectinload(RolePermission.permission)
)
)
return result.scalar_one()
test_app.dependency_overrides[get_db_session] = override_get_db_session
test_app.dependency_overrides[get_current_active_user] = override_get_current_active_user
transport = ASGITransport(app=test_app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
test_app.dependency_overrides.clear()
async def _grant_permission(session, user, code):
"""给测试 user 加指定 permission_code。
注意:User.is_superuser 是 @property(派生自 role.code == "admin"),
不能直接赋值;admin 权限通过给 user 关联 'admin' role 触发。
"""
# 找/创建 permission
perm_row = await session.execute(select(Permission).where(Permission.code == code))
perm = perm_row.scalar_one_or_none()
if perm is None:
perm = Permission(code=code, name=code, module="moldinsight")
session.add(perm)
await session.flush()
# 找/创建 role(用 permission code 作 role code,便于复用)
role_row = await session.execute(select(Role).where(Role.code == code))
role = role_row.scalar_one_or_none()
if role is None:
role = Role(code=code, name=code, is_system=False)
session.add(role)
await session.flush()
rp = RolePermission(role_id=role.id, permission_id=perm.id)
session.add(rp)
# 关联 user(如未关联)
user_role_row = await session.execute(
select(UserRole).where(UserRole.user_id == user.id, UserRole.role_id == role.id)
)
if user_role_row.scalar_one_or_none() is None:
session.add(UserRole(user_id=user.id, role_id=role.id))
await session.commit()
# ── POST /experience-feedback 测试 ──
async def test_submit_feedback_403_without_permission(feedback_client, async_engine):
"""tester 默认无任何权限 → 403。"""
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
# 确保 tester 没有 admin role 也没有 feedback_experience_hint role
async with session_factory() as session:
await session.execute(
UserRole.__table__.delete().where(UserRole.user_id == 1)
)
await session.commit()
resp = await feedback_client.post(
"/tasks/task-demo-1/experience-feedback",
json={
"scheme_id": "scheme_1",
"feedback_status": "adopted",
},
)
assert resp.status_code == 403, resp.text
assert "工艺工程师" in resp.text
async def test_submit_feedback_200_with_feedback_permission(feedback_client, async_engine):
"""给 tester 授予 feedback_experience_hint → 200 + 写入经验反馈。"""
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
async with session_factory() as session:
tester = (await session.execute(select(User).where(User.username == "tester"))).scalar_one()
await _grant_permission(session, tester, "feedback_experience_hint")
resp = await feedback_client.post(
"/tasks/task-demo-1/experience-feedback",
json={
"scheme_id": "scheme_1",
"feedback_status": "adopted",
"feedback_reason": "工艺验证 OK",
"confidence_at_submit": 0.85,
"score_at_submit": 87.5,
},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["scheme_id"] == "scheme_1"
assert body["feedback_status"] == "adopted"
# DB 真的写入了
async with session_factory() as session:
result = await session.execute(
select(ExperienceFeedback).where(ExperienceFeedback.scheme_id == "scheme_1")
)
fb = result.scalar_one()
assert fb.user_id == tester.id
assert fb.feedback_status == "adopted"
assert fb.role_code == "feedback_experience_hint" # 写入时角色归因
assert fb.expires_at is not None
async def test_submit_feedback_200_with_admin(feedback_client, async_engine):
"""admin role → has_permission 走 role.code=='admin' 短路 → 200。"""
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
async with session_factory() as session:
tester = (await session.execute(select(User).where(User.username == "tester"))).scalar_one()
await _grant_permission(session, tester, "admin")
resp = await feedback_client.post(
"/tasks/task-demo-1/experience-feedback",
json={
"scheme_id": "scheme_2",
"feedback_status": "rejected",
},
)
assert resp.status_code == 200, resp.text
assert resp.json()["scheme_axis"] # 自动从 cavity_key_info 解析,缺则默认 Z
async def test_submit_feedback_invalid_status_returns_422(feedback_client, async_engine):
"""feedback_status 非法 → Pydantic 校验 422。"""
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
async with session_factory() as session:
tester = (await session.execute(select(User).where(User.username == "tester"))).scalar_one()
await _grant_permission(session, tester, "admin")
resp = await feedback_client.post(
"/tasks/task-demo-1/experience-feedback",
json={
"scheme_id": "scheme_1",
"feedback_status": "approve", # 非法值
},
)
assert resp.status_code == 422
# ── GET /experience-hints 测试 ──
async def test_get_hints_200_empty(feedback_client, async_engine):
"""无反馈历史 → 空 hints 列表,fingerprint 回显。"""
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
async with session_factory() as session:
tester = (await session.execute(select(User).where(User.username == "tester"))).scalar_one()
await _grant_permission(session, tester, "admin")
resp = await feedback_client.get("/tasks/task-demo-1/experience-hints")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["task_id"] == "task-demo-1"
assert body["stp_file_id"] == 1
assert body["hints"] == []
assert "bbox_aspect" in body["fingerprint"]
async def test_get_hints_aggregates_by_axis(feedback_client, async_engine):
"""写入多条反馈后 GET hints 按 axis 聚合。"""
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
async with session_factory() as session:
tester = (await session.execute(select(User).where(User.username == "tester"))).scalar_one()
await _grant_permission(session, tester, "admin")
# 写入 4 条反馈,全部落在 axis="Z" 默认(无 cavity_key_info)
for fb in [
{"scheme_id": "x_1", "feedback_status": "adopted"},
{"scheme_id": "x_2", "feedback_status": "adopted"},
{"scheme_id": "x_3", "feedback_status": "rejected"},
{"scheme_id": "z_1", "feedback_status": "adopted"},
]:
resp = await feedback_client.post(
"/tasks/task-demo-1/experience-feedback",
json=fb,
)
assert resp.status_code == 200, resp.text
# GET hints
resp = await feedback_client.get("/tasks/task-demo-1/experience-hints")
assert resp.status_code == 200, resp.text
body = resp.json()
# 无 cavity_key_info 时所有 feedback 落在 axis="Z" 默认值 → 4 条聚合
assert len(body["hints"]) == 1
h = body["hints"][0]
assert h["scheme_axis"] == "Z"
assert h["adopted_count"] == 3
assert h["rejected_count"] == 1
assert h["sample_count"] == 4
assert h["confidence"] == 0.5 # (3-1)/4
async def test_submit_feedback_increments_expires_at(feedback_client, async_engine):
"""同 stp_file_id 上写入新反馈时,旧行的 expires_at 应被续期(write-time 续期)。"""
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
async with session_factory() as session:
tester = (await session.execute(select(User).where(User.username == "tester"))).scalar_one()
await _grant_permission(session, tester, "admin")
# 写入第一条反馈
resp = await feedback_client.post(
"/tasks/task-demo-1/experience-feedback",
json={"scheme_id": "scheme_1", "feedback_status": "adopted"},
)
assert resp.status_code == 200
# 拿到第一条 expires_at
async with session_factory() as session:
first = (await session.execute(
select(ExperienceFeedback).where(ExperienceFeedback.scheme_id == "scheme_1")
)).scalar_one()
first_expires = first.expires_at
assert first_expires is not None
# 写第二条(不同 scheme_id),应触发同 stp_file 续期
import asyncio
await asyncio.sleep(0.05)
resp = await feedback_client.post(
"/tasks/task-demo-1/experience-feedback",
json={"scheme_id": "scheme_2", "feedback_status": "rejected"},
)
assert resp.status_code == 200
# 第一条 expires_at 应被续期(≥ 原值)
async with session_factory() as session:
first_after = (await session.execute(
select(ExperienceFeedback).where(ExperienceFeedback.scheme_id == "scheme_1")
)).scalar_one()
assert first_after.expires_at >= first_expires
# ── 任务归属校验 ──
async def test_submit_feedback_with_unknown_task_returns_404(feedback_client, async_engine):
"""task_id 不存在 → ensure_task_access 返回 404(不是 500)。
D9 边界保护:service.record_feedback 永远走不到(ensure_task_access 先拦截)。
"""
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
async with session_factory() as session:
tester = (await session.execute(select(User).where(User.username == "tester"))).scalar_one()
await _grant_permission(session, tester, "admin")
resp = await feedback_client.post(
"/tasks/non-existent-task-id/experience-feedback",
json={"scheme_id": "scheme_1", "feedback_status": "adopted"},
)
assert resp.status_code == 404
assert "不存在" in resp.text
# ── 配置:D17 模型注册收口 ──
def test_experience_feedback_registered_in_metadata():
"""D17:experience_feedback 表已加入 Base.metadata(防止漏注册导致 ORM 不可用)。"""
configure_mappers()
assert "experience_feedback" in Base.metadata.tables