"""共享夹具。 分两层: - 纯逻辑测试:不连数据库、不调模型、不开浏览器,随时可跑。 - `@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]