"""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