from datetime import datetime from zoneinfo import ZoneInfo from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool from app.core.config import get_settings from app.db.models import AgentRun, Base, Signal from app.db.session import get_db from app.main import create_app def test_health_endpoint_returns_app_identity(monkeypatch) -> None: # 健康检查只暴露服务身份,不触发Agent运行或数据库写入。 monkeypatch.setenv("SIGNALSCOUT_ENV", "test") get_settings.cache_clear() with TestClient(create_app()) as client: response = client.get("/health") get_settings.cache_clear() assert response.status_code == 200 assert response.json()["status"] == "ok" assert response.json()["app"] == "SignalScout" def test_home_page_returns_dashboard_shell(monkeypatch) -> None: # 根路径应该展示业务工作台,而不是返回接口404。 monkeypatch.setenv("SIGNALSCOUT_ENV", "test") get_settings.cache_clear() with TestClient(create_app()) as client: response = client.get("/") get_settings.cache_clear() assert response.status_code == 200 assert "SignalScout" in response.text assert "今日 AI 信号" in response.text assert "手动搜索" not in response.text assert "renderMarkdownReport" in response.text assert "自动记录" not in response.text assert "采集今日情报" not in response.text def test_manual_agent_run_endpoint_starts_background_job_when_enabled(monkeypatch) -> None: # 启用采集后手动入口复用调度任务,方便验证每轮候选和入库数量。 monkeypatch.setenv("SIGNALSCOUT_ENV", "test") monkeypatch.setenv("SIGNALSCOUT_AGENT_ENABLED", "true") get_settings.cache_clear() started = [] engine = create_engine( "sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, future=True, ) TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) Base.metadata.create_all(engine) def fake_run_scheduled_job(settings): started.append(settings.env) def override_get_db(): # 手动搜索测试只验证接口调度行为,运行中状态查询使用内存库。 db = TestingSessionLocal() try: yield db finally: db.close() monkeypatch.setattr("app.api.routes.run_scheduled_job", fake_run_scheduled_job) app = create_app() app.dependency_overrides[get_db] = override_get_db with TestClient(app) as client: response = client.post("/agent/runs/daily") get_settings.cache_clear() assert response.status_code == 200 assert response.json()["status"] == "started" assert started == ["test"] def test_manual_agent_run_endpoint_uses_scheduler_when_available(monkeypatch) -> None: # 生产环境手动搜索投递到常驻调度器,避免HTTP请求承载长时间联网搜索。 monkeypatch.setenv("SIGNALSCOUT_ENV", "test") monkeypatch.setenv("SIGNALSCOUT_AGENT_ENABLED", "true") get_settings.cache_clear() engine = create_engine( "sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, future=True, ) TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) Base.metadata.create_all(engine) scheduled = [] class FakeScheduler: running = True timezone = ZoneInfo("Asia/Shanghai") def add_job(self, *args, **kwargs): scheduled.append(kwargs) def override_get_db(): # 调度器路径同样使用内存库检查运行中状态。 db = TestingSessionLocal() try: yield db finally: db.close() app = create_app() app.state.scheduler = FakeScheduler() app.dependency_overrides[get_db] = override_get_db with TestClient(app) as client: response = client.post("/agent/runs/daily") get_settings.cache_clear() assert response.status_code == 200 assert response.json()["status"] == "started" assert scheduled[0]["trigger"] == "date" assert scheduled[0]["run_date"].tzinfo is not None def test_manual_agent_run_endpoint_rejects_requests_when_disabled(monkeypatch) -> None: # 采集暂停时接口拒绝请求,避免其他调用方绕过页面入口消耗模型额度。 monkeypatch.setenv("SIGNALSCOUT_ENV", "test") monkeypatch.setenv("SIGNALSCOUT_AGENT_ENABLED", "false") get_settings.cache_clear() with TestClient(create_app()) as client: response = client.post("/agent/runs/daily") get_settings.cache_clear() assert response.status_code == 503 assert response.json()["detail"] == "情报采集当前已暂停" def test_dashboard_returns_news_and_github_signals_separately(monkeypatch) -> None: # 仪表盘接口直接返回分组后的业务数据,前端不再用标题或来源名自行猜测分类。 monkeypatch.setenv("SIGNALSCOUT_ENV", "test") get_settings.cache_clear() engine = create_engine( "sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, future=True, ) TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) Base.metadata.create_all(engine) with TestingSessionLocal() as session: run = AgentRun(status="completed", started_at=datetime(2026, 7, 8, 8, 0), request_payload={}) session.add(run) session.flush() session.add_all( [ Signal( run_id=run.id, source_type="news", topic="AI 新闻", title="New model release", summary="模型发布动态。", source_url="https://example.com/news", source_url_hash="news-hash", source_name="Example News", published_at=datetime(2026, 7, 8, 9, 0), importance=4, entities=["Example"], ), Signal( run_id=run.id, source_type="github", topic="GitHub 热门项目", title="example/agent-kit", summary="开源项目动态。", source_url="https://github.com/example/agent-kit", source_url_hash="github-hash", source_name="GitHub", published_at=datetime(2026, 7, 8, 10, 0), importance=5, entities=["example/agent-kit"], ), ] ) session.commit() def override_get_db(): # 测试依赖把接口请求限制在内存库里,避免触碰开发数据库。 db = TestingSessionLocal() try: yield db finally: db.close() app = create_app() app.dependency_overrides[get_db] = override_get_db with TestClient(app) as client: dashboard_response = client.get("/dashboard") github_response = client.get("/signals", params={"source_type": "github"}) get_settings.cache_clear() dashboard = dashboard_response.json() assert dashboard_response.status_code == 200 assert [item["source_type"] for item in dashboard["news_signals"]] == ["news"] assert [item["source_type"] for item in dashboard["github_signals"]] == ["github"] assert github_response.status_code == 200 assert [item["title"] for item in github_response.json()] == ["example/agent-kit"] def test_dashboard_uses_latest_completed_run_signals(monkeypatch) -> None: # 情报页展示最新完成运行的快照,历史运行只保留在记录和明细接口里。 monkeypatch.setenv("SIGNALSCOUT_ENV", "test") get_settings.cache_clear() engine = create_engine( "sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, future=True, ) TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) Base.metadata.create_all(engine) with TestingSessionLocal() as session: old_run = AgentRun(status="completed", started_at=datetime(2026, 7, 8, 8, 0), request_payload={}) new_run = AgentRun(status="completed", started_at=datetime(2026, 7, 8, 9, 0), request_payload={}) session.add_all([old_run, new_run]) session.flush() session.add_all( [ Signal( run_id=old_run.id, source_type="news", topic="AI 新闻", title="Old signal", summary="旧运行信号。", source_url="https://example.com/old", source_url_hash="old-hash", source_name="Example", published_at=datetime(2026, 7, 8, 8, 0), importance=3, entities=[], ), Signal( run_id=new_run.id, source_type="news", topic="AI 新闻", title="New signal", summary="新运行信号。", source_url="https://example.com/new", source_url_hash="new-hash", source_name="Example", published_at=datetime(2026, 7, 8, 9, 0), importance=4, entities=[], ), ] ) session.commit() def override_get_db(): # 仪表盘快照测试使用内存库构造多运行场景。 db = TestingSessionLocal() try: yield db finally: db.close() app = create_app() app.dependency_overrides[get_db] = override_get_db with TestClient(app) as client: response = client.get("/dashboard") get_settings.cache_clear() assert response.status_code == 200 assert [item["title"] for item in response.json()["news_signals"]] == ["New signal"]