from __future__ import annotations import os import pytest import pytest_asyncio from sqlalchemy import JSON, select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine os.environ["DATABASE_URL"] = "postgresql+asyncpg://pmflow:pmflow_pass@127.0.0.1:5435/pmflow" from app.models.base import Base from app.models.project import Project from app.models.project_signal_link import ProjectSignalLink from app.models.signal import Signal from app.models.signal_source_registry import SignalSourceRegistry from app.models.user import User from app.services.signal.signal_source_repair import SignalSourceRepairService def _use_sqlite_json_types() -> None: for table in (User.__table__, SignalSourceRegistry.__table__): for column in table.columns: if column.name in {"assigned_domains", "config", "raw_context"}: 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=[ User.__table__, Project.__table__, Signal.__table__, ProjectSignalLink.__table__, SignalSourceRegistry.__table__, ], ) ) maker = async_sessionmaker(engine, expire_on_commit=False) async with maker() as session: yield session await engine.dispose() @pytest.mark.asyncio async def test_repair_registries_materializes_formal_signal_without_capture(db_session, monkeypatch) -> None: owner = User( tenant_id=1, username="owner", email="owner@example.com", password_hash="hash", name="负责人", role="REVIEWER", status="active", is_active=True, ) project = Project( tenant_id=1, name="cashflow", title="现金流项目", status="active", assigned_role="backend", ) db_session.add_all([owner, project]) await db_session.flush() registry = SignalSourceRegistry( tenant_id=1, project_id=int(project.id), created_by_user_id=int(owner.id), source_kind="github", source_name="现金流日报仓库", source_identifier="daily_cashflow_analysis", description="每日现金流分析脚本仓库", requested_via="ai_steward", status="pending", onboarding_status="pending", config={}, raw_context={}, ) db_session.add(registry) await db_session.commit() service = SignalSourceRepairService(db_session) async def _fail_if_capture_called(**kwargs): # noqa: ANN001 _ = kwargs raise AssertionError("repair should materialize Signal directly instead of calling capture flow") async def _noop_emit_visibility_records(**kwargs): # noqa: ANN001 _ = kwargs monkeypatch.setattr(service, "_capture_registry", _fail_if_capture_called) monkeypatch.setattr(service, "_emit_visibility_records", _noop_emit_visibility_records) result = await service.repair_registries( tenant_id=1, project_id=int(project.id), registry_ids=[int(registry.id)], sender="PMFlow修复器", ) assert len(result) == 1 assert result[0]["capture_status"] == "materialized" assert result[0]["signal_id"] is not None refreshed_registry = ( await db_session.execute(select(SignalSourceRegistry).where(SignalSourceRegistry.id == int(registry.id))) ).scalar_one() assert refreshed_registry.signal_id == int(result[0]["signal_id"]) signal = ( await db_session.execute(select(Signal).where(Signal.id == int(result[0]["signal_id"]))) ).scalar_one() assert signal.title == "现金流日报仓库" assert signal.matched_project_id == int(project.id) assert signal.source == "daily_cashflow_analysis" link = ( await db_session.execute( select(ProjectSignalLink).where( ProjectSignalLink.project_id == int(project.id), ProjectSignalLink.signal_id == int(signal.id), ) ) ).scalar_one_or_none() assert link is not None @pytest.mark.asyncio async def test_repair_registries_relinks_existing_project_signal_before_materializing(db_session, monkeypatch) -> None: owner = User( tenant_id=1, username="owner2", email="owner2@example.com", password_hash="hash", name="负责人2", role="REVIEWER", status="active", is_active=True, ) project = Project( tenant_id=1, name="cashflow-existing", title="现金流项目-已有信号", status="active", assigned_role="backend", ) db_session.add_all([owner, project]) await db_session.flush() signal = Signal( tenant_id=1, source="cashflow_alert_subscription", content="已有现金流预警信号", impact_score=0.7, priority="high", processed=False, strategic_analysis="{}", tasks_generated="[]", matched_project_id=None, title="现金流预警系统需求", signal_type="新需求", summary="现金流健康评分与预警系统", reasoning="[]", confidence=0.9, recommended_action="enter_only", status="待处理", ) db_session.add(signal) await db_session.flush() db_session.add( ProjectSignalLink( tenant_id=1, project_id=int(project.id), signal_id=int(signal.id), link_type="related", relevance_score=1.0, ) ) registry = SignalSourceRegistry( tenant_id=1, project_id=int(project.id), created_by_user_id=int(owner.id), source_kind="webhook", source_name="现金流预警订阅", source_identifier="cashflow_alert_subscription", description="企业订阅现金流预警,当健康评分低于阈值时推送通知", requested_via="ai_steward", status="pending", onboarding_status="pending", config={}, raw_context={}, ) db_session.add(registry) await db_session.commit() service = SignalSourceRepairService(db_session) async def _noop_emit_visibility_records(**kwargs): # noqa: ANN001 _ = kwargs monkeypatch.setattr(service, "_emit_visibility_records", _noop_emit_visibility_records) result = await service.repair_registries( tenant_id=1, project_id=int(project.id), registry_ids=[int(registry.id)], sender="PMFlow修复器", ) assert len(result) == 1 assert result[0]["capture_status"] == "relinked" assert result[0]["signal_id"] == int(signal.id) refreshed_registry = ( await db_session.execute(select(SignalSourceRegistry).where(SignalSourceRegistry.id == int(registry.id))) ).scalar_one() assert refreshed_registry.signal_id == int(signal.id) refreshed_signal = ( await db_session.execute(select(Signal).where(Signal.id == int(signal.id))) ).scalar_one() assert refreshed_signal.matched_project_id == int(project.id) signals = (await db_session.execute(select(Signal).order_by(Signal.id.asc()))).scalars().all() assert len(signals) == 1