import json import os import tempfile from datetime import date, timedelta from pathlib import Path import pytest from fastapi import HTTPException from sqlalchemy import select from app.modules.ai_agent.constants import AIProviderName, AIResponseKey from app.modules.business.constants import BusinessResponseKey, StatusValue _db = tempfile.NamedTemporaryFile(delete=False, suffix=".db") _db.close() os.environ["DATABASE_URL"] = "sqlite:///" + _db.name.replace("\\", "/") os.environ["API_KEY"] = "test-key" os.environ["AUDIT_API_KEY"] = "audit-key" os.environ["AUDIT_API_ACTOR"] = "audit-manager" os.environ["FEISHU_APP_ID"] = "" os.environ["FEISHU_APP_SECRET"] = "" os.environ["FEISHU_VERIFICATION_TOKEN"] = "test-feishu-token" os.environ["LEGACY_ALLOWED_QUERIES"] = "{}" os.environ["LEGACY_DATABASE_URL"] = "" os.environ["LEGACY_PROJECT_QUERY"] = "" os.environ["MODEL_PROVIDER"] = AIProviderName.NOOP os.environ["SCHEDULER_ENABLED"] = "false" from fastapi.testclient import TestClient from app.core.config import Settings, get_settings from app.core.database import Base, SessionLocal, engine from app.core.http.pagination import bounded_limit, bounded_offset from app.core.security import require_api_key, require_audit_api_key from app.main import _allow_cors_credentials, app from app.modules.audit.constants import AUDIT_REDACTED_VALUE from app.modules.ai_memory.constants import ( AIMemoryPayloadKey, AIMemoryResponseKey, AIMemoryStatus, ) from app.modules.events.constants import ( EventAggregateType, EventPayloadKey, EventSource, EventStatus, EventType, ) from app.modules.events.service import EventService from app.modules.business.registry import get_domain_model from app.modules.business.service import _model_payload, serialize_model from app.modules.legacy_mysql.service import LegacyMySQLService from app.modules.observability.constants import ( HeartbeatComponent, ObservabilityKey, ) from app.modules.observability.service import ObservabilityService from app.modules.reports.constants import ( EnterpriseAnalyticsKey, LifecycleAttentionKey, LifecycleResponseKey, LifecycleSection, MetricKey, ReportPushStatus, ReportTitle, ReportType, ) from app.modules.risk.constants import RiskEventActionValue from app.modules.workflows.constants import WorkflowStatus, WorkflowType from app.modules.workflows.models import WorkflowInstance Base.metadata.create_all(bind=engine) client = TestClient(app) headers = {"X-API-Key": "test-key"} audit_headers = {"X-API-Key": "test-key", "X-Audit-API-Key": "audit-key"} class SeedResponse: def __init__(self, payload: dict, status_code: int = 200): self.status_code = status_code self._payload = payload def json(self) -> dict: return self._payload def create_business_record(domain: str, data: dict, actor: str = "pytest") -> SeedResponse: _ = actor db = SessionLocal() try: model = get_domain_model(domain) record = model(**_model_payload(domain, model, data)) db.add(record) db.commit() db.refresh(record) return SeedResponse( { BusinessResponseKey.DOMAIN: domain, BusinessResponseKey.DATA: serialize_model(record), } ) finally: db.close() def teardown_module() -> None: engine.dispose() path = Path(_db.name) if path.exists(): path.unlink() def test_project_report_and_feishu_command_preview() -> None: response = create_business_record( "projects", { "code": "P-SMOKE-001", "name": "Smoke Project", "owner": "tester", "status": "执行中", "budget_amount": 1000, "actual_amount": 200, }, ) assert response.status_code == 200 assert response.json()["data"]["code"] == "P-SMOKE-001" response = client.get("/api/v1/reports/daily-brief", headers=headers) assert response.status_code == 200 assert response.json()["title"] == "每日经营晨报" response = client.post( "/api/v1/integrations/feishu/commands/preview", headers=headers, json={"text": "日报", "auto_reply": False}, ) assert response.status_code == 200 assert response.json()["command"] == "daily_brief" def test_feishu_webhook_routes_message_event() -> None: payload = { "schema": "2.0", "header": { "event_id": "evt-smoke-risk-001", "event_type": "im.message.receive_v1", "token": "test-feishu-token", }, "event": { "sender": {"sender_id": {"open_id": "ou_test"}}, "message": { "chat_id": "oc_test", "message_id": "om_smoke_risk_001", "message_type": "text", "content": json.dumps({"text": "risk"}), }, }, } response = client.post("/api/v1/integrations/feishu/webhook", json=payload) assert response.status_code == 200 data = response.json() assert data["handled"] is True assert data["result"]["command"] == "risk_summary" duplicate_response = client.post("/api/v1/integrations/feishu/webhook", json=payload) assert duplicate_response.status_code == 200 assert duplicate_response.json()["duplicate"] is True blocked_logs_response = client.get("/api/v1/audit/logs", headers=headers) assert blocked_logs_response.status_code == 401 logs_response = client.get("/api/v1/audit/logs", headers=audit_headers) assert logs_response.status_code == 200 audit_payload = json.dumps(logs_response.json(), ensure_ascii=False) assert "test-feishu-token" not in audit_payload assert AUDIT_REDACTED_VALUE in audit_payload def test_v3_request_id_health_and_metrics() -> None: db = SessionLocal() try: ObservabilityService(db).record_heartbeat( component=HeartbeatComponent.WORKER, instance_id="pytest-worker", actor="pytest", ) finally: db.close() response = client.get("/api/v1/health/live", headers={"X-Request-ID": "rid-v3-smoke"}) assert response.status_code == 200 assert response.headers["X-Request-ID"] == "rid-v3-smoke" assert response.json()["status"] == "ok" response = client.get("/api/v1/health/ready") assert response.status_code == 200 assert response.json()["status"] in {"ok", "degraded"} response = client.get("/api/v1/metrics", headers=headers) assert response.status_code == 200 metrics = response.json()["metrics"] assert ObservabilityKey.EVENTS in metrics assert ObservabilityKey.HEARTBEATS in metrics def test_v3_event_idempotency_and_workflow_dispatch() -> None: from app.core.database import SessionLocal db = SessionLocal() try: service = EventService(db) event = service.emit( event_type=EventType.RISK_ACTION_RECORDED, source=EventSource.RISK, aggregate_type=EventAggregateType.RISK_EVENT, aggregate_id="risk-v3-idem", actor="pytest", payload={ EventPayloadKey.ACTION: RiskEventActionValue.ASSIGN, EventPayloadKey.STATUS: StatusValue.OPEN, }, idempotency_key="v3-risk-idempotency", dispatch=True, ) duplicate = service.emit( event_type=EventType.RISK_ACTION_RECORDED, source=EventSource.RISK, aggregate_type=EventAggregateType.RISK_EVENT, aggregate_id="risk-v3-idem", actor="pytest", payload={ EventPayloadKey.ACTION: RiskEventActionValue.ASSIGN, EventPayloadKey.STATUS: StatusValue.OPEN, }, idempotency_key="v3-risk-idempotency", dispatch=True, ) assert duplicate.event_id == event.event_id assert event.status == EventStatus.PROCESSED workflow = db.execute( select(WorkflowInstance).where( WorkflowInstance.workflow_type == WorkflowType.RISK_EVENT_REVIEW, WorkflowInstance.aggregate_id == "risk-v3-idem", ) ).scalar_one() assert workflow.status == WorkflowStatus.RUNNING finally: db.close() def test_v3_event_retry_and_dispatch_pending_route() -> None: db = SessionLocal() try: event = EventService(db).emit( event_type=EventType.REPORT_PUSH_FAILED, source=EventSource.REPORTS, aggregate_type=EventAggregateType.REPORT_PUSH_RUN, aggregate_id="push-v3-retry", actor="pytest", payload={ EventPayloadKey.CODE: "push-v3-retry", EventPayloadKey.STATUS: ReportPushStatus.FAILED, }, idempotency_key="v3-report-push-retry", ) event.status = EventStatus.FAILED event.last_error = "transient" event.attempts = event.max_attempts db.commit() event_id = event.event_id finally: db.close() retry_response = client.post(f"/api/v1/events/{event_id}/retry", headers=headers) assert retry_response.status_code == 200 assert retry_response.json()["event"]["status"] == EventStatus.PENDING assert retry_response.json()["event"]["attempts"] == 0 dispatch_response = client.post("/api/v1/events/dispatch-pending", headers=headers) assert dispatch_response.status_code == 200 dispatched = [ item for item in dispatch_response.json()["items"] if item["event_id"] == event_id ] assert dispatched assert dispatched[0]["status"] == EventStatus.PROCESSED def test_v3_ai_memory_recall_and_auto_write() -> None: response = client.post( "/api/v1/ai/ask", headers=headers, json={ "prompt": "Summarize quarterly cash planning for project memory smoke", "context": { AIMemoryPayloadKey.SCOPE: "project", AIMemoryPayloadKey.SUBJECT: "P-MEM-SMOKE", }, }, ) assert response.status_code == 200 data = response.json() assert data[AIResponseKey.PROVIDER] == AIProviderName.NOOP assert data[AIResponseKey.RAW][AIResponseKey.MEMORY_WRITE][AIMemoryPayloadKey.STATUS] == ( AIMemoryStatus.ACTIVE ) list_response = client.get( "/api/v1/ai/memory?scope=project&subject=P-MEM-SMOKE", headers=headers, ) assert list_response.status_code == 200 assert list_response.json()[AIMemoryResponseKey.ITEMS] recall_response = client.post( "/api/v1/ai/memory/recall", headers=headers, json={ "query": "quarterly cash planning", "scope": "project", "subject": "P-MEM-SMOKE", }, ) assert recall_response.status_code == 200 assert recall_response.json()[AIMemoryResponseKey.ITEMS] def test_v3_risk_action_routes_are_disabled_in_read_only_mode() -> None: response = create_business_record( "risk-events", { "code": "RISK-V3-WF-001", "title": "V3 workflow risk", "risk_type": "manual", "source_domain": "projects", }, ) assert response.status_code == 200 risk_id = response.json()["data"]["id"] response = client.post( f"/api/v1/risks/events/{risk_id}/assign", headers=headers, json={"assigned_to": "risk-owner", "comment": "route to owner"}, ) assert response.status_code == 405 def test_writeback_and_approval_routes_are_removed() -> None: response = client.post( "/api/v1/writebacks", headers=headers, json={ "domain": "projects", "record_id": "P-V3-WB", "action": "sync", "payload": {"code": "P-V3-WB", "name": "Writeback target"}, }, ) assert response.status_code == 404 response = client.post( "/api/v1/approvals", headers=headers, json={ "domain": "projects", "record_id": "P-V3-WB", "action": "writeback:projects", "reason": "V3 writeback gate", "payload": {"code": "P-V3-WB", "name": "Writeback target"}, }, ) assert response.status_code == 404 def test_feishu_webhook_challenge_uses_event_service_verification() -> None: response = client.post( "/api/v1/integrations/feishu/webhook", json={ "challenge": "challenge-token", "token": "test-feishu-token", }, ) assert response.status_code == 200 assert response.json()["challenge"] == "challenge-token" def test_api_key_and_feishu_webhook_fail_closed(monkeypatch) -> None: monkeypatch.setenv("API_KEY", "") get_settings.cache_clear() try: with pytest.raises(HTTPException) as exc_info: require_api_key("test-key") assert exc_info.value.status_code == 503 monkeypatch.setenv("API_KEY", "test-key") get_settings.cache_clear() response = client.post( "/api/v1/integrations/feishu/webhook", json={"schema": "2.0", "header": {"event_type": "im.message.receive_v1"}}, ) assert response.status_code == 401 monkeypatch.setenv("AUDIT_API_KEY", "") get_settings.cache_clear() with pytest.raises(HTTPException) as audit_exc_info: require_audit_api_key("audit-key") assert audit_exc_info.value.status_code == 503 finally: monkeypatch.setenv("API_KEY", "test-key") monkeypatch.setenv("AUDIT_API_KEY", "audit-key") get_settings.cache_clear() def test_service_key_rotation_config(monkeypatch) -> None: monkeypatch.setenv("API_KEY", "") monkeypatch.setenv( "API_KEYS", json.dumps( [ {"key": "disabled-key", "actor": "disabled", "enabled": False}, {"key": "rotated-key", "actor": "rotated-api", "enabled": True}, ] ), ) get_settings.cache_clear() try: assert require_api_key("rotated-key").actor == "rotated-api" with pytest.raises(HTTPException) as exc_info: require_api_key("disabled-key") assert exc_info.value.status_code == 401 finally: monkeypatch.setenv("API_KEY", "test-key") monkeypatch.delenv("API_KEYS", raising=False) get_settings.cache_clear() def test_config_and_pagination_guardrails() -> None: settings = Settings(cors_origins='["https://app.example.com", "https://admin.example.com"]') assert settings.cors_origins == ["https://app.example.com", "https://admin.example.com"] assert _allow_cors_credentials(["*"]) is False assert _allow_cors_credentials(["https://app.example.com"]) is True assert bounded_limit(-1) == 1 assert bounded_limit(1000) == 500 assert bounded_offset(-10) == 0 negative_limit_response = client.get("/api/v1/business/projects?limit=-1", headers=headers) assert negative_limit_response.status_code == 422 oversized_limit_response = client.get("/api/v1/risks/events?limit=501", headers=headers) assert oversized_limit_response.status_code == 422 negative_offset_response = client.get("/api/v1/business/projects?offset=-1", headers=headers) assert negative_offset_response.status_code == 422 def test_dashboard_and_response_masking() -> None: expense_response = create_business_record( "expenses", { "code": "EXP-MASK-001", "expense_type": "办公", "amount": 20, "payment_account": "6222000000000000", }, ) assert expense_response.status_code == 200 masked_response = client.get("/api/v1/business/expenses", headers=headers) assert masked_response.status_code == 200 masked_items = masked_response.json()["items"] assert any(item["code"] == "EXP-MASK-001" for item in masked_items) assert next( item["payment_account"] for item in masked_items if item["code"] == "EXP-MASK-001" ) == "[MASKED]" dashboard_response = client.get("/api/v1/dashboard/summary", headers=headers) assert dashboard_response.status_code == 200 assert "metrics" in dashboard_response.json() def test_configured_domain_response_masking(monkeypatch) -> None: monkeypatch.setenv("MASKED_RESPONSE_FIELDS", json.dumps(["expenses.amount"])) get_settings.cache_clear() try: response = create_business_record( "expenses", { "code": "EXP-MASK-CONFIG-001", "expense_type": "测试", "amount": 123, }, ) assert response.status_code == 200 list_response = client.get("/api/v1/business/expenses", headers=headers) assert list_response.status_code == 200 item = next( item for item in list_response.json()["items"] if item["code"] == "EXP-MASK-CONFIG-001" ) assert item["amount"] == "[MASKED]" finally: monkeypatch.delenv("MASKED_RESPONSE_FIELDS", raising=False) get_settings.cache_clear() def test_business_write_routes_are_disabled_in_read_only_mode() -> None: create_response = client.post( "/api/v1/business/projects", headers=headers, json={ "data": { "code": "P-APPROVAL-BLOCKED", "name": "Blocked project", }, }, ) assert create_response.status_code == 405 update_response = client.patch( "/api/v1/business/projects/1", headers=headers, json={ "data": { "name": "Blocked update", }, }, ) assert update_response.status_code == 405 def test_approval_and_feishu_approval_card_routes_are_removed() -> None: approval_response = client.post( "/api/v1/approvals", headers=headers, json={ "domain": "fund-accounts", "record_id": "feishu-card-test", "action": "update:fund-accounts", "reason": "Card action smoke test", "payload": {"current_balance": 300}, }, ) assert approval_response.status_code == 404 callback_response = client.post( "/api/v1/integrations/feishu/approval-card-action", json={ "token": "test-feishu-token", "operator": {"operator_id": {"open_id": "ou_card_approver"}}, "action": { "value": { "ticket_id": "APR-DISABLED", "decision": "approve", "comment": "approved from card", } }, }, ) assert callback_response.status_code == 404 def test_new_ledgers_reports_and_risk_events() -> None: domains_response = client.get("/api/v1/business/domains", headers=headers) assert domains_response.status_code == 200 domains = domains_response.json()["domains"] assert "attendance-records" in domains assert "work-reports" in domains assert "risk-events" in domains today = date.today() attendance_response = create_business_record( "attendance-records", { "code": "ATT-SMOKE-001", "employee_name": "Tester", "department": "QA", "work_date": today.isoformat(), "status": "正常", }, ) assert attendance_response.status_code == 200 task_response = create_business_record( "tasks", { "code": "TASK-RISK-001", "title": "Overdue smoke task", "owner": "tester", "status": "待办", "due_date": (today - timedelta(days=1)).isoformat(), }, ) assert task_response.status_code == 200 attendance_summary = client.get("/api/v1/reports/attendance-summary", headers=headers) assert attendance_summary.status_code == 200 assert attendance_summary.json()["total"] >= 1 report_response = client.post( "/api/v1/reports/work-reports/generate", headers=headers, json={"report_type": ReportType.DAILY, "reporter": "pytest", "actor": "pytest"}, ) assert report_response.status_code == 200 assert report_response.json()["data"] is None assert report_response.json()["report"]["report_type"] == ReportType.DAILY risk_response = client.post( "/api/v1/risks/events/generate?actor=pytest", headers=headers, ) assert risk_response.status_code == 405 enqueue_response = client.post("/api/v1/risks/events/enqueue", headers=headers) assert enqueue_response.status_code == 405 overdue_response = client.get("/api/v1/risks/overdue-tasks", headers=headers) assert overdue_response.status_code == 200 assert any(item["code"] == "TASK-RISK-001" for item in overdue_response.json()["items"]) def test_project_lifecycle_report_summarizes_progress_cost_and_risk() -> None: today = date.today() project_code = "P-LIFECYCLE-001" project_response = create_business_record( "projects", { "code": project_code, "name": "Lifecycle Project", "owner": "lifecycle-owner", "status": "执行中", "progress_percent": 40, "budget_amount": 1000, "actual_amount": 1500, "due_date": (today - timedelta(days=1)).isoformat(), }, ) assert project_response.status_code == 200 task_response = create_business_record( "tasks", { "code": "TASK-LIFECYCLE-001", "title": "Lifecycle overdue task", "project_code": project_code, "owner": "lifecycle-owner", "status": "待办", "due_date": (today - timedelta(days=1)).isoformat(), "blocker": "waiting for decision", }, ) assert task_response.status_code == 200 procurement_response = create_business_record( "procurements", { "code": "PROC-LIFECYCLE-001", "name": "Lifecycle procurement", "project_code": project_code, "expected_amount": 300, "actual_amount": 100, "approval_status": StatusValue.PENDING_APPROVAL, "delivery_status": StatusValue.UNDELIVERED, "payment_status": StatusValue.UNPAID, }, ) assert procurement_response.status_code == 200 expense_response = create_business_record( "expenses", { "code": "EXP-LIFECYCLE-001", "expense_type": "差旅", "amount": 80, "project_code": project_code, "approval_status": StatusValue.PENDING_APPROVAL, "payment_status": StatusValue.UNPAID, }, ) assert expense_response.status_code == 200 attendance_response = create_business_record( "attendance-records", { "code": "ATT-LIFECYCLE-001", "employee_name": "Lifecycle Tester", "project_code": project_code, "work_date": today.isoformat(), "status": StatusValue.MISSING_PUNCH, }, ) assert attendance_response.status_code == 200 response = client.get( f"/api/v1/reports/project-lifecycle?project_code={project_code}", headers=headers, ) assert response.status_code == 200 data = response.json() assert data[LifecycleResponseKey.TITLE] == ReportTitle.PROJECT_LIFECYCLE assert data[LifecycleResponseKey.METRICS][LifecycleSection.PROJECTS][MetricKey.TOTAL] == 1 assert data[LifecycleResponseKey.METRICS][LifecycleSection.PROJECTS][MetricKey.DELAYED] == 1 assert ( data[LifecycleResponseKey.METRICS][LifecycleSection.PROJECTS][MetricKey.OVER_BUDGET] == 1 ) assert data[LifecycleResponseKey.METRICS][LifecycleSection.TASKS][MetricKey.OVERDUE] == 1 assert ( data[LifecycleResponseKey.METRICS][LifecycleSection.PROCUREMENTS][ MetricKey.PENDING_APPROVAL ] == 1 ) assert ( data[LifecycleResponseKey.METRICS][LifecycleSection.EXPENSES][ MetricKey.PENDING_APPROVAL ] == 1 ) assert data[LifecycleResponseKey.METRICS][LifecycleSection.ATTENDANCE][MetricKey.ABNORMAL] == 1 assert ( data[LifecycleResponseKey.ATTENTION][LifecycleAttentionKey.DELAYED_PROJECTS][0]["code"] == project_code ) assert "生命周期健康分" in data[LifecycleResponseKey.CONTENT] assert data[LifecycleResponseKey.RECOMMENDATIONS] ai_response = client.get( f"/api/v1/reports/project-lifecycle?project_code={project_code}&include_ai=true", headers=headers, ) assert ai_response.status_code == 200 ai_data = ai_response.json() assert ai_data[LifecycleResponseKey.AI_ANALYSIS][AIResponseKey.OK] is True assert ( ai_data[LifecycleResponseKey.AI_ANALYSIS][AIResponseKey.PROVIDER] == AIProviderName.NOOP ) def test_v3_enterprise_analytics_returns_read_only_sections() -> None: performance_response = create_business_record( "performance-metrics", { "code": "PERF-V3-001", "name": "V3 delivery score", "weight": 20, "auto_score": 82, "confirmed_score": 78, "status": StatusValue.REVIEWED, }, ) assert performance_response.status_code == 200 response = client.get("/api/v1/reports/enterprise-analytics", headers=headers) assert response.status_code == 200 data = response.json() assert data[EnterpriseAnalyticsKey.TITLE] == ReportTitle.ENTERPRISE_ANALYTICS assert EnterpriseAnalyticsKey.FINANCE in data assert EnterpriseAnalyticsKey.PROCUREMENT in data assert EnterpriseAnalyticsKey.PERFORMANCE in data assert EnterpriseAnalyticsKey.OPERATIONS in data assert data[EnterpriseAnalyticsKey.PERFORMANCE][MetricKey.CONFIRMED] >= 1 db = SessionLocal() try: workflow = db.execute( select(WorkflowInstance).where( WorkflowInstance.workflow_type == WorkflowType.ENTERPRISE_ANALYTICS, WorkflowInstance.aggregate_id == data[EnterpriseAnalyticsKey.CODE], ) ).scalar_one() assert workflow.status == WorkflowStatus.COMPLETED finally: db.close() def test_work_report_counts_pending_approval_backlog_outside_period() -> None: today = date.today() project_code = "P-BACKLOG-001" report_day = today - timedelta(days=7) procurement_response = create_business_record( "procurements", { "code": "PROC-BACKLOG-001", "name": "Backlog procurement", "project_code": project_code, "approval_status": StatusValue.PENDING_APPROVAL, }, ) assert procurement_response.status_code == 200 expense_response = create_business_record( "expenses", { "code": "EXP-BACKLOG-001", "expense_type": "办公", "amount": 50, "project_code": project_code, "approval_status": StatusValue.PENDING_APPROVAL, }, ) assert expense_response.status_code == 200 report_response = client.post( "/api/v1/reports/work-reports/generate", headers=headers, json={ "report_type": ReportType.DAILY, "project_code": project_code, "period_start": report_day.isoformat(), "period_end": report_day.isoformat(), "persist": False, }, ) assert report_response.status_code == 200 metrics = report_response.json()["report"]["metrics"] assert metrics["procurements_pending"] == 1 assert metrics["expenses_pending"] == 1 def test_legacy_task_read_query_allowed_but_sync_disabled(monkeypatch) -> None: rows = [ { "id": 9001, "task_name": "Legacy task one", "project_code": "P-SMOKE-001", "owner": "legacy-owner", "status": "待办", } ] def fake_execute_allowed_query(self, query_name, params=None, limit=100): return {"columns": list(rows[0]), "rows": rows, "row_count": len(rows)} monkeypatch.setattr( LegacyMySQLService, "execute_allowed_query", fake_execute_allowed_query, ) query_response = client.post( "/api/v1/integrations/mysql/query", headers=headers, json={"query_name": "legacy_tasks"}, ) assert query_response.status_code == 200 assert query_response.json()["row_count"] == 1 assert query_response.json()["rows"][0]["task_name"] == "Legacy task one" response = client.post( "/api/v1/integrations/mysql/tasks/sync", headers=headers, json={ "dry_run": False, "field_map": {"title": "task_name"}, }, ) assert response.status_code == 405 def test_risk_event_action_routes_are_disabled_in_read_only_mode() -> None: create_response = create_business_record( "risk-events", { "code": "RISK-FLOW-001", "title": "Workflow risk", "risk_type": "manual", "risk_level": "medium", "source_domain": "projects", "source_record_id": "P-SMOKE-001", "status": "open", }, ) assert create_response.status_code == 200 event_id = create_response.json()["data"]["id"] assign_response = client.post( f"/api/v1/risks/events/{event_id}/assign", headers=headers, json={"assigned_to": "risk-owner", "comment": "please handle"}, ) assert assign_response.status_code == 405 comment_response = client.post( f"/api/v1/risks/events/{event_id}/comment", headers=headers, json={"comment": "working on it", "payload": {"step": 1}}, ) assert comment_response.status_code == 405 resolve_response = client.post( f"/api/v1/risks/events/{event_id}/resolve", headers=headers, json={"comment": "resolved"}, ) assert resolve_response.status_code == 405 close_response = client.post( f"/api/v1/risks/events/{event_id}/close", headers=headers, json={ "closed_reason": "verified", "review_summary": "handled", }, ) assert close_response.status_code == 405 reopen_response = client.post( f"/api/v1/risks/events/{event_id}/reopen", headers=headers, json={"comment": "recheck"}, ) assert reopen_response.status_code == 405 actions_response = client.get( f"/api/v1/risks/events/{event_id}/actions", headers=headers, ) assert actions_response.status_code == 200 assert actions_response.json()["items"] == [] def test_report_push_failure_is_recorded() -> None: response = client.post( "/api/v1/reports/daily-brief/push", headers=headers, json={"receive_id": "oc_missing_config"}, ) assert response.status_code == 503 runs_response = client.get( f"/api/v1/reports/push-runs?status={ReportPushStatus.FAILED}", headers=headers, ) assert runs_response.status_code == 200 assert any( item["title"] == ReportTitle.DAILY_BRIEF for item in runs_response.json()["items"] ) dashboard_response = client.get("/api/v1/dashboard/summary", headers=headers) assert dashboard_response.status_code == 200 assert dashboard_response.json()["metrics"]["failed_push_runs"] >= 1 def test_ai_noop_provider() -> None: response = client.post( "/api/v1/ai/ask", headers=headers, json={ "prompt": "生成项目摘要", "actor": "pytest", "context": {"project": "P-SMOKE-001"}, }, ) assert response.status_code == 200 assert response.json()[AIResponseKey.PROVIDER] == AIProviderName.NOOP def test_legacy_project_payload_does_not_create_legacy_none_code() -> None: payload = LegacyMySQLService(None)._project_payload({"name": "Missing Id"}, {}) assert payload["code"] is None assert payload["external_id"] is None def test_legacy_readonly_query_requires_allowlist(monkeypatch) -> None: monkeypatch.delenv("LEGACY_PROJECT_QUERY", raising=False) get_settings.cache_clear() try: with pytest.raises(HTTPException) as blocked_exc_info: LegacyMySQLService(None).execute_readonly("SELECT id FROM secret_projects") assert blocked_exc_info.value.status_code == 403 monkeypatch.setenv("LEGACY_PROJECT_QUERY", "SELECT id FROM projects") get_settings.cache_clear() def unavailable_engine(): raise HTTPException(status_code=503, detail="legacy unavailable") monkeypatch.setattr( LegacyMySQLService, "_ensure_engine", staticmethod(unavailable_engine), ) with pytest.raises(HTTPException) as engine_exc_info: LegacyMySQLService(None).execute_readonly("SELECT id FROM projects") assert engine_exc_info.value.status_code == 503 finally: monkeypatch.delenv("LEGACY_PROJECT_QUERY", raising=False) get_settings.cache_clear() def test_legacy_readonly_query_clamps_param_limit(monkeypatch) -> None: captured: dict[str, dict] = {} class FakeResult: def mappings(self) -> "FakeResult": return self def all(self) -> list: return [] class FakeConnection: def __enter__(self) -> "FakeConnection": return self def __exit__(self, exc_type, exc, traceback) -> None: return None def execute(self, statement, params): captured["params"] = params return FakeResult() class FakeEngine: def connect(self) -> FakeConnection: return FakeConnection() monkeypatch.setenv("LEGACY_PROJECT_QUERY", "SELECT id FROM projects LIMIT :limit") get_settings.cache_clear() monkeypatch.setattr( LegacyMySQLService, "_ensure_engine", staticmethod(lambda: FakeEngine()), ) try: result = LegacyMySQLService(None).execute_readonly( "SELECT id FROM projects LIMIT :limit", {"limit": 9999}, limit=9999, ) assert result["row_count"] == 0 assert captured["params"]["limit"] == 500 with pytest.raises(HTTPException) as exc_info: LegacyMySQLService(None).execute_readonly( "SELECT id FROM projects LIMIT :limit", {"limit": "invalid"}, ) assert exc_info.value.status_code == 422 finally: monkeypatch.delenv("LEGACY_PROJECT_QUERY", raising=False) get_settings.cache_clear()