193 lines
7.2 KiB
Python
193 lines
7.2 KiB
Python
"""共享夹具。
|
||
|
||
分两层:
|
||
- 纯逻辑测试:不连数据库、不调模型、不开浏览器,随时可跑。
|
||
- `@pytest.mark.db` 测试:需要本机 PostgreSQL(`npm run db:up`),不可达时整组跳过。
|
||
它们把 `settings.school_id` 换成一次性的测试学校,靠多校隔离避免污染 demo_school,跑完删干净。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import socket
|
||
import sys
|
||
import uuid
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
if str(ROOT) not in sys.path:
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
from backend.config import settings # noqa: E402
|
||
|
||
|
||
def _pg_reachable() -> bool:
|
||
s = socket.socket()
|
||
s.settimeout(1.0)
|
||
try:
|
||
s.connect((settings.pg_host, settings.pg_port))
|
||
return True
|
||
except OSError:
|
||
return False
|
||
finally:
|
||
s.close()
|
||
|
||
|
||
PG_UP = _pg_reachable()
|
||
|
||
|
||
@pytest.fixture(scope="session", autouse=True)
|
||
def bootstrap_database_tests(request):
|
||
"""全新开发环境也能跑数据库用例,历史中心服务测试使用一次性库。"""
|
||
if not PG_UP or not any(item.get_closest_marker("db") for item in request.session.items):
|
||
yield
|
||
return
|
||
from psycopg import sql
|
||
from backend.db import database as local
|
||
from server.db import database as hub
|
||
|
||
local.init_db(retries=1, delay=0)
|
||
original = hub.settings.pg_database
|
||
temporary = f"pytest_hub_{uuid.uuid4().hex[:12]}"
|
||
hub.settings.pg_database = temporary
|
||
try:
|
||
hub.init_db(retries=1, delay=0)
|
||
yield
|
||
finally:
|
||
hub.settings.pg_database = original
|
||
conn = hub._connect(hub.settings.pg_maintenance_database)
|
||
try:
|
||
conn.autocommit = True
|
||
conn.execute(sql.SQL("DROP DATABASE IF EXISTS {} WITH (FORCE)").format(sql.Identifier(temporary)))
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
def pytest_collection_modifyitems(config, items):
|
||
if PG_UP:
|
||
return
|
||
skip = pytest.mark.skip(reason=f"PostgreSQL {settings.pg_host}:{settings.pg_port} 不可达,跳过数据库测试(npm run db:up)")
|
||
for item in items:
|
||
if "db" in item.keywords:
|
||
item.add_marker(skip)
|
||
|
||
|
||
@pytest.fixture
|
||
def test_school(monkeypatch):
|
||
"""把本次测试隔离到一所一次性的学校,退出时删掉它的全部数据。"""
|
||
school_id = f"pytest_{uuid.uuid4().hex[:10]}"
|
||
monkeypatch.setattr(settings, "school_id", school_id)
|
||
yield school_id
|
||
cleanup_school(school_id)
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def isolate_ranked_recall_in_unit_tests(request, monkeypatch):
|
||
# Unit tests use in-memory task stores. They must not read the user's
|
||
# live knowledge workspace; DB integration tests exercise the real store.
|
||
if request.node.get_closest_marker("db") is None:
|
||
from backend.prospecting import orchestrator
|
||
monkeypatch.setattr(orchestrator, "ranked_experience", lambda task: [])
|
||
monkeypatch.setattr(orchestrator, "persist_knowledge", lambda task, artifacts: artifacts)
|
||
from backend.prospecting import routes
|
||
monkeypatch.setattr(routes, "save_business_feedback", lambda *args: None)
|
||
|
||
|
||
# 带 school_id 的表(settings 是全局键值表、run_logs 挂在 task 上,都不带)
|
||
SCHOOL_SCOPED_TABLES = ("learned_entries", "tasks", "system_accounts", "knowledge", "skills")
|
||
|
||
|
||
def cleanup_school(school_id: str) -> None:
|
||
from backend.db import database as db
|
||
|
||
with db.get_conn() as conn:
|
||
for table in ("prospecting_validation_events", "prospecting_business_feedback", "prospecting_methods"):
|
||
db._execute(conn, f"DELETE FROM {table} WHERE workspace_id = %s", (school_id,))
|
||
db._execute(conn, "DELETE FROM run_logs WHERE task_id IN (SELECT id FROM tasks WHERE school_id = %s)", (school_id,))
|
||
for table in SCHOOL_SCOPED_TABLES:
|
||
db._execute(conn, f"DELETE FROM {table} WHERE school_id = %s", (school_id,))
|
||
|
||
|
||
@pytest.fixture
|
||
def knowledge() -> dict:
|
||
"""一份精简的知识包:两个业务、一个系统,形状与 knowledge/xuegong_demo.yaml 一致。"""
|
||
return {
|
||
"id": "test_pack",
|
||
"name": "测试业务包",
|
||
"systems": [
|
||
{
|
||
"id": "xgxt",
|
||
"name": "学工系统",
|
||
"url": "https://example.edu/xuegong/",
|
||
"allowed_domains": ["example.edu", "*.example.edu"],
|
||
"login": {"fallback_mode": "quick_login"},
|
||
},
|
||
{
|
||
"id": "lib",
|
||
"name": "图书馆",
|
||
"url": "https://lib.example.edu/",
|
||
"allowed_domains": ["lib.example.edu"],
|
||
"login": {"fallback_mode": "form"},
|
||
},
|
||
],
|
||
"businesses": [
|
||
{
|
||
"id": "counselor_worklog",
|
||
"name": "辅导员工作日志填报(查课)",
|
||
"role": "辅导员",
|
||
"system": "xgxt",
|
||
"intent": "辅导员填写工作日志、每月查课日志、听课记录",
|
||
"required_inputs": [
|
||
{"name": "class_name", "label": "班级", "type": "text", "required": True},
|
||
{"name": "observe_date", "label": "听课日期", "type": "date", "required": True},
|
||
{"name": "course_name", "label": "课程名称", "type": "text", "required": True},
|
||
{"name": "location", "label": "听课地点", "type": "text", "required": False},
|
||
],
|
||
"workflow": [
|
||
{"step_id": "explore", "enabled": False, "goal": "只读探索"},
|
||
{
|
||
"step_id": "fill_and_submit",
|
||
"enabled": True,
|
||
"goal": "填写 {class_name} 的查课日志",
|
||
"human_confirm_before": "submit",
|
||
"on_success_status": "submitted",
|
||
"success_artifacts_any": ["submitted_record", "result_message", "cancelled"],
|
||
"prerequisites": [
|
||
{
|
||
"id": "return_verification",
|
||
"title": "返校核验",
|
||
"resolution": "other_role",
|
||
"advice": "联系辅导员先完成返校核验",
|
||
},
|
||
{
|
||
"id": "profile_complete",
|
||
"title": "完善个人信息",
|
||
"resolution": "self",
|
||
"business_id": "profile_fill",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
"tracking": {
|
||
"interval_minutes": 60,
|
||
"max_checks": 24,
|
||
"step": {"goal": "回查审批状态"},
|
||
},
|
||
},
|
||
{
|
||
"id": "generic_explore",
|
||
"name": "通用探索",
|
||
"system": "xgxt",
|
||
"intent": "看看有什么、随便找找",
|
||
"required_inputs": [],
|
||
"workflow": [{"step_id": "explore", "enabled": True, "goal": "探索"}],
|
||
},
|
||
],
|
||
}
|
||
|
||
|
||
@pytest.fixture
|
||
def business(knowledge) -> dict:
|
||
return knowledge["businesses"][0]
|