import json from datetime import date, datetime, timedelta import pytest from fastapi import HTTPException from sqlalchemy import select, text from app.modules.ai_agent.constants import AIProviderName, AIResponseKey from app.modules.business.constants import BusinessResponseKey, StatusValue from fastapi.testclient import TestClient from app.core.config import Settings, get_settings from app.application.scheduling import create_scheduler from app.core.database import Base, SessionLocal, engine from app.core.database.migrations import expected_alembic_heads from app.core.http.pagination import bounded_limit, bounded_offset from app.core.http.responses import MaskedJSONResponse from app.core.security import require_api_key, require_audit_api_key from app.main import _allow_cors_credentials, app 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.models import DomainEvent from app.modules.events.services import EventService from app.application.events import EventDispatchService from app.modules.business.registry import get_domain_model from app.modules.business.models import ( Employee, Project, ProjectCashFlow, ProjectContract, ProjectMember, ProjectMilestone, MarketDailyQuote, MarketAnnouncement, MarketFinancialMetric, MarketInstrument, MarketMacroIndicator, ) from app.modules.business.service import _model_payload, serialize_model from app.modules.legacy_mysql.services import LegacyMySQLService from app.modules.legacy_mysql.intasect import ( CONTRACT_RECEIVABLE_SQL, CONTRACT_SQL, EPOCH, IntasectSyncService, PROJECT_FUND_SQL, _contract_payload, _contract_receivable_payload, _employee_payload, _project_fund_payload, _project_payload, ) from app.modules.business.constants import ( CashFlowDirection, CashFlowType, DataQualityStatus, ) from app.modules.ai_memory.service import AIMemoryService from app.modules.ai_memory.models import AIMemoryEntry 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, ReportPushType, ReportResponseKey, ReportTitle, ReportType, ) from app.application.pipelines import LifecyclePipelineService from app.application.delivery import ReportDeliveryService from app.modules.reports.chart import render_lifecycle_chart from app.modules.reports.services import ReportService from app.modules.feishu.service import FeishuService from app.modules.feishu.models import FeishuEventReceipt from app.application.feishu import FeishuCommandService from app.application.feishu.events import ( FeishuEventService, _audit_event_metadata, _identifier_digest, ) from app.modules.feishu.constants import FeishuEventSource, FeishuInboundStatus from app.modules.risk.constants import RiskEventActionValue from app.modules.market.chart import render_market_chart from app.modules.market.service import MarketService, TushareClient, normalize_symbol from app.application.pipelines import MarketPipelineService from app.modules.workflows.constants import WorkflowStatus, WorkflowType from app.modules.workflows.models import WorkflowInstance Base.metadata.create_all(bind=engine) with engine.begin() as connection: connection.execute( text( "CREATE TABLE IF NOT EXISTS alembic_version " "(version_num VARCHAR(32) NOT NULL)" ) ) connection.execute(text("DELETE FROM alembic_version")) connection.execute( text("INSERT INTO alembic_version (version_num) VALUES (:revision)"), {"revision": expected_alembic_heads()[0]}, ) 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 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["accepted"] is True assert data["handled"] is False assert data["status"] == FeishuInboundStatus.PENDING with SessionLocal() as db: receipt = db.scalar( select(FeishuEventReceipt).where( FeishuEventReceipt.event_id == _identifier_digest( "evt-smoke-risk-001", "event-id", ) ) ) assert receipt is not None assert receipt.status == FeishuInboundStatus.SUCCEEDED assert receipt.payload is None audit_metadata = _audit_event_metadata(payload) assert "content" not in json.dumps(audit_metadata) assert "test-feishu-token" not in json.dumps(audit_metadata) duplicate_response = client.post("/api/v1/integrations/feishu/webhook", json=payload) assert duplicate_response.status_code == 200 assert duplicate_response.json()["duplicate"] is True assert duplicate_response.json()["status"] == FeishuInboundStatus.SUCCEEDED 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 "evt-smoke-risk-001" not in audit_payload assert ( _identifier_digest("evt-smoke-risk-001", "event-id") in audit_payload ) def test_feishu_rule_commands_create_list_disable_and_enable(monkeypatch) -> None: monkeypatch.setenv("READ_ONLY_MODE", "false") get_settings.cache_clear() monkeypatch.setattr( FeishuService, "send_text", lambda *args, **kwargs: pytest.fail("auto_reply=False must not send to Feishu"), ) rule_text = "每日建议必须说明负责人角色、截止时间和验收指标" created = client.post( "/api/v1/integrations/feishu/commands/preview", headers=headers, json={"text": f"学习规则 80:{rule_text}", "auto_reply": False}, ) assert created.status_code == 200 assert created.json()["command"] == "rule_create" assert "规则已学习" in created.json()["content"] db = SessionLocal() try: rule = db.execute( select(AIMemoryEntry).where(AIMemoryEntry.content == rule_text) ).scalar_one() assert rule.importance == 80 assert rule.scope == "global" assert rule.subject == "company" assert "feishu" in rule.tags service = FeishuCommandService(db) listed = service.handle_text("查看规则", auto_reply=False) assert listed["command"] == "rule_list" assert rule.code in listed["content"] disabled = service.handle_text(f"停用规则 {rule.code}", auto_reply=False) assert disabled["command"] == "rule_disable" assert "已停用" in disabled["content"] db.refresh(rule) assert rule.status == AIMemoryStatus.ARCHIVED enabled = service.handle_text(f"启用规则 {rule.code}", auto_reply=False) assert enabled["command"] == "rule_enable" assert "已启用" in enabled["content"] db.refresh(rule) assert rule.status == AIMemoryStatus.ACTIVE finally: db.close() monkeypatch.delenv("READ_ONLY_MODE", raising=False) get_settings.cache_clear() def test_feishu_rule_command_preserves_sender_and_rejects_invalid_input(monkeypatch) -> None: monkeypatch.setenv("READ_ONLY_MODE", "false") get_settings.cache_clear() payload = { "schema": "2.0", "header": { "event_id": "evt-smoke-rule-actor-001", "event_type": "im.message.receive_v1", "token": "test-feishu-token", }, "event": { "sender": {"sender_id": {"open_id": "ou_rule_teacher"}}, "message": { "chat_id": "oc_test", "message_id": "om_smoke_rule_actor_001", "message_type": "text", "content": json.dumps({"text": "学习规则:风险建议先写事实依据再写行动"}), }, }, } db = SessionLocal() try: result = FeishuEventService(db).handle_event( payload, source=FeishuEventSource.WEBHOOK, auto_reply=False, ) assert result["result"]["command"] == "rule_create" rule = db.execute( select(AIMemoryEntry).where(AIMemoryEntry.content == "风险建议先写事实依据再写行动") ).scalar_one() assert rule.actor == "ou_rule_teacher" empty = FeishuCommandService(db).handle_text("学习规则:", auto_reply=False) assert empty["command"] == "rule_create" assert "不能为空" in empty["content"] invalid_priority = FeishuCommandService(db).handle_text( "学习规则 101:先写结论", auto_reply=False, ) assert "1 到 100" in invalid_priority["content"] secret = FeishuCommandService(db).handle_text( "学习规则:请保存 password=example", auto_reply=False, ) assert "已拒绝学习" in secret["content"] finally: db.close() monkeypatch.delenv("READ_ONLY_MODE", raising=False) get_settings.cache_clear() 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", ) event = EventDispatchService(db).dispatch_event( event.event_id, worker_id="pytest-idempotency", ) 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", ) duplicate = EventDispatchService(db).dispatch_event( duplicate.event_id, worker_id="pytest-idempotency-duplicate", ) 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_noop_does_not_auto_write_memory() -> None: response = client.post( "/api/v1/ai/ask", headers=headers, json={ "prompt": "Remember that project updates should use concise bullet summaries", "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.OK] is False assert AIResponseKey.MEMORY_WRITE not in data[AIResponseKey.RAW] 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] == [] def test_ai_memory_rejects_financial_facts_and_applies_retention() -> None: db = SessionLocal() try: memory = AIMemoryService(db).auto_write( prompt="Remember the project cash flow and budget details for next quarter", context={ AIMemoryPayloadKey.SCOPE: "project", AIMemoryPayloadKey.SUBJECT: "P-MEM-FINANCIAL", }, answer="Retain the confidential cash flow and budget details.", actor="pytest", ) assert memory is not None assert memory.status == AIMemoryStatus.REJECTED finally: db.close() rejected = client.get( "/api/v1/ai/memory", headers=headers, params={ "scope": "project", "subject": "P-MEM-FINANCIAL", "status": AIMemoryStatus.REJECTED, }, ).json()[AIMemoryResponseKey.ITEMS] assert rejected assert rejected[0]["expires_at"] is not None assert "cash flow" not in rejected[0][AIMemoryPayloadKey.CONTENT].lower() def test_default_json_response_masks_sensitive_fields() -> None: response = MaskedJSONResponse( { "token": "provider-token", "nested": {"password": "provider-password", "status": "ok"}, } ) payload = json.loads(response.body) assert payload["token"] == "[MASKED]" assert payload["nested"]["password"] == "[MASKED]" assert payload["nested"]["status"] == "ok" def test_v3_risk_action_routes_write_local_state_in_read_only_mode(monkeypatch) -> None: monkeypatch.setenv("READ_ONLY_MODE", "true") get_settings.cache_clear() 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 == 200 assert response.json()["risk_event"]["assigned_to"] == "risk-owner" @pytest.mark.parametrize("read_only_mode", ["true", "false"]) def test_writeback_and_approval_routes_are_removed(monkeypatch, read_only_mode: str) -> None: monkeypatch.setenv("READ_ONLY_MODE", read_only_mode) get_settings.cache_clear() 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 get_settings.cache_clear() 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() assert "latest_audit_logs" not 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() @pytest.mark.parametrize("read_only_mode", ["true", "false"]) def test_business_write_routes_are_permanently_disabled(monkeypatch, read_only_mode: str) -> None: monkeypatch.setenv("READ_ONLY_MODE", read_only_mode) get_settings.cache_clear() 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 get_settings.cache_clear() @pytest.mark.parametrize("read_only_mode", ["true", "false"]) def test_approval_and_feishu_approval_card_routes_are_removed( monkeypatch, read_only_mode: str ) -> None: monkeypatch.setenv("READ_ONLY_MODE", read_only_mode) get_settings.cache_clear() 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 openclaw_response = client.post( "/api/v1/ai/openclaw/tools/invoke", headers=headers, json={ "tool": "sessions_list", "action": "json", "args": {}, "session_key": "main", }, ) assert openclaw_response.status_code == 404 assert "/api/v1/approvals" not in app.openapi()["paths"] assert "/api/v1/integrations/feishu/approval-card-action" not in app.openapi()["paths"] assert "/api/v1/ai/openclaw/tools/invoke" not in app.openapi()["paths"] get_settings.cache_clear() def test_new_ledgers_reports_and_local_risk_state(monkeypatch) -> None: monkeypatch.setenv("READ_ONLY_MODE", "true") get_settings.cache_clear() monkeypatch.setattr( "app.modules.risk.routes.enqueue_risk_event_generation", lambda actor: {"queued": True, "actor": actor}, ) 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", "persist": True, }, ) assert report_response.status_code == 200 assert report_response.json()["data"]["reporter"] == "pytest" 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 == 200 enqueue_response = client.post("/api/v1/risks/events/enqueue", headers=headers) assert enqueue_response.status_code == 200 assert enqueue_response.json()["queued"] is True 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_independent_feishu_report_schedules_and_tasks_are_registered() -> None: from app.application.scheduling import create_scheduler from app.tasks.constants import ( TASK_PUSH_ATTENDANCE_SUMMARY, TASK_PUSH_DAILY_BRIEF, TASK_PUSH_PROJECT_WEEKLY, TASK_PUSH_RISK_PROGRESS, TASK_PUSH_WORK_DAILY, TASK_PUSH_WORK_WEEKLY, ) from app.tasks import celery_app scheduler = create_scheduler() job_ids = {job.id for job in scheduler.get_jobs()} assert { "daily_brief_push", "project_weekly_push", "attendance_summary_push", "risk_progress_push", "work_daily_push", "work_weekly_push", } <= job_ids task_names = { TASK_PUSH_DAILY_BRIEF, TASK_PUSH_PROJECT_WEEKLY, TASK_PUSH_ATTENDANCE_SUMMARY, TASK_PUSH_RISK_PROGRESS, TASK_PUSH_WORK_DAILY, TASK_PUSH_WORK_WEEKLY, } assert task_names <= set(celery_app.tasks) def test_independent_feishu_report_surfaces_are_available() -> None: from app.modules.reports.services import ReportService route_paths = {route.path for route in app.routes} assert { "/api/v1/reports/attendance-summary/push", "/api/v1/reports/attendance-summary/enqueue", "/api/v1/reports/risk-progress/push", "/api/v1/reports/risk-progress/enqueue", "/api/v1/reports/work-daily/push", "/api/v1/reports/work-daily/enqueue", "/api/v1/reports/work-weekly/push", "/api/v1/reports/work-weekly/enqueue", } <= route_paths risk_response = client.get("/api/v1/reports/risk-progress", headers=headers) assert risk_response.status_code == 200 assert risk_response.json()["title"] == ReportTitle.RISK_PROGRESS assert risk_response.json()["report_type"] == ReportPushType.RISK_PROGRESS db = SessionLocal() try: service = ReportService(db) work_daily = service.work_daily_report(reporter="pytest", actor="pytest") work_weekly = service.work_weekly_report(reporter="pytest", actor="pytest") finally: db.close() assert work_daily[ReportResponseKey.REPORT_TYPE] == ReportPushType.WORK_DAILY assert work_weekly[ReportResponseKey.REPORT_TYPE] == ReportPushType.WORK_WEEKLY 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 False assert ai_data[LifecycleResponseKey.AI_ANALYSIS][AIResponseKey.PROVIDER] == AIProviderName.NOOP assert "不可用" in ai_data[LifecycleResponseKey.AI_ANALYSIS][AIResponseKey.ANSWER] 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: event = db.execute( select(DomainEvent).where( DomainEvent.idempotency_key == f"enterprise-analytics:{data[EnterpriseAnalyticsKey.CODE]}" ) ).scalar_one() assert event.status == EventStatus.PENDING EventDispatchService(db).dispatch_event( event.event_id, worker_id="pytest-enterprise", ) 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_read_query_and_local_import_allowed_in_read_only_mode(monkeypatch) -> None: monkeypatch.setenv("READ_ONLY_MODE", "true") get_settings.cache_clear() 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 == 200 assert response.json()["dry_run"] is False assert response.json()["items"][0]["task"]["source_system"] == "legacy_mysql" get_settings.cache_clear() def test_risk_event_actions_write_local_state_in_read_only_mode(monkeypatch) -> None: monkeypatch.setenv("READ_ONLY_MODE", "true") get_settings.cache_clear() 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 == 200 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 == 200 resolve_response = client.post( f"/api/v1/risks/events/{event_id}/resolve", headers=headers, json={"comment": "resolved"}, ) assert resolve_response.status_code == 200 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 == 200 reopen_response = client.post( f"/api/v1/risks/events/{event_id}/reopen", headers=headers, json={"comment": "recheck"}, ) assert reopen_response.status_code == 200 actions_response = client.get( f"/api/v1/risks/events/{event_id}/actions", headers=headers, ) assert actions_response.status_code == 200 assert len(actions_response.json()["items"]) == 5 get_settings.cache_clear() 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 fetchmany(self, size: int) -> list: _ = size 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() def test_intasect_mapping_uses_stable_ids_and_preserves_unknown_stage() -> None: seen_at = datetime(2026, 7, 12, 9, 0) project = _project_payload( { "source_id": 99, "business_code": None, "pro_sn": None, "name": "Lifecycle Project", "mgr_deptid": 7, "mgr_deptname": "Delivery", "mgr_user_id": 8, "mgr_user_name": "Manager", "project_stage": "ZZYGD", "stage_label": "ZZYGD", "archive_flag": "0", "source_created_at": seen_at, "source_updated_at": seen_at, "contract_date": None, "project_information": None, }, seen_at, ) employee = _employee_payload( { "source_id": 8, "employee_name": "Employee", "dept_id": 7, "dept_name": "Delivery", "employment_status": "0", "ding_id": None, "title": "Engineer", "hired_date": None, "source_created_at": seen_at, "source_updated_at": seen_at, }, seen_at, ) assert project["code"] == "INTASECT-PROJECT-99" assert project["display_code"] is None assert project["source_stage_label"] == "ZZYGD" assert project["progress_percent"] == 0 assert employee["code"] == "INTASECT-EMPLOYEE-8" assert employee["ding_user_id"] is None def test_intasect_full_sync_marks_missing_projects_inactive() -> None: row = { "source_key": "99101", "source_id": 99101, "business_code": "B-99101", "pro_sn": None, "name": "Synced Project", "mgr_deptid": None, "mgr_deptname": None, "mgr_user_id": None, "mgr_user_name": None, "project_stage": "XMQD", "stage_label": "项目启动", "archive_flag": "0", "source_created_at": datetime(2026, 1, 1), "source_updated_at": datetime(2026, 7, 1), "contract_date": None, "project_done_date": None, "project_information": None, } class FakeSource: def __init__(self, rows): self.rows = rows def fetch_page(self, dataset, after_key, watermark_at, limit): assert dataset == "projects" assert watermark_at == EPOCH return self.rows if not after_key else [] db = SessionLocal() try: IntasectSyncService(db, FakeSource([row])).sync_dataset("projects", "RUN-1") project = db.execute(select(Project).where(Project.external_id == "99101")).scalar_one() assert project.is_active is True assert project.display_code == "B-99101" IntasectSyncService(db, FakeSource([])).sync_dataset("projects", "RUN-2") db.refresh(project) assert project.is_active is False finally: db.close() def test_personnel_lifecycle_does_not_treat_missing_ding_mapping_as_absence() -> None: db = SessionLocal() try: employee = Employee( code="INTASECT-EMPLOYEE-99102", name="Lifecycle Employee", department_name="Delivery", employment_status="在职", source_system="legacy_mysql", external_id="99102", ding_user_id=None, is_active=True, ) project = Project( code="INTASECT-PROJECT-99102", name="Lifecycle Report Project", status=StatusValue.RUNNING, source_system="legacy_mysql", external_id="99102", source_archived=False, is_active=True, ) db.add_all([employee, project]) db.flush() db.add( ProjectMember( code="INTASECT-MEMBER-99102", project_code=project.code, employee_code=employee.code, workload_percent=120, source_system="legacy_mysql", external_id="99102", is_active=True, ) ) db.add( ProjectMilestone( code="INTASECT-MILESTONE-99102", project_code=project.code, source_stage_id="stage-1", stage_name="项目启动", plan_end=date.today() - timedelta(days=1), status=StatusValue.RUNNING, is_overdue=True, source_system="legacy_mysql", external_id="99102", is_active=True, ) ) db.commit() report = ReportService(db).personnel_lifecycle_report( employee_code=employee.code, project_code=project.code, ) item = report["items"][0] assert item["attendance_covered"] is False assert item["attendance_abnormal"] == 0 assert item["needs_attention"] is True management = ReportService(db).management_lifecycle_report( ReportType.DAILY, include_ai=True, ) assert management["metrics"]["projects"]["milestones"]["overdue"] >= 1 assert management["ai_analysis"]["ok"] is False assert "降级为确定性基础报告" not in management["content"] assert "首期未接入" in management["content"] finally: db.close() def test_lifecycle_pipeline_is_idempotent(monkeypatch) -> None: monkeypatch.setenv("READ_ONLY_MODE", "true") get_settings.cache_clear() monkeypatch.setattr( IntasectSyncService, "sync_all", lambda self, run_code, force_full=False, batch_size=500: {"projects": {"processed": 1}}, ) monkeypatch.setattr( ReportService, "management_lifecycle_report", lambda self, report_type, actor, include_ai: { "title": "Lifecycle", "report_type": report_type, "lines": ["ok"], "content": "ok", "ai_analysis": {"ok": True, "answer": "analysis"}, }, ) monkeypatch.setattr( ReportDeliveryService, "push_report", lambda self, *args, **kwargs: {"ok": True}, ) db = SessionLocal() try: service = LifecyclePipelineService(db) first = service.run(ReportType.WEEKLY, actor="pytest") second = service.run(ReportType.WEEKLY, actor="pytest") assert first["deduplicated"] is False assert second["deduplicated"] is True assert first["workflow_code"] == second["workflow_code"] finally: db.close() monkeypatch.delenv("READ_ONLY_MODE", raising=False) get_settings.cache_clear() def test_lifecycle_enqueue_allows_local_work_in_read_only_mode(monkeypatch) -> None: monkeypatch.setenv("READ_ONLY_MODE", "true") get_settings.cache_clear() monkeypatch.setattr( "app.modules.reports.routes.enqueue_lifecycle_report", lambda **kwargs: {"status": "queued", "report_type": kwargs["report_type"]}, ) try: response = client.post( "/api/v1/reports/lifecycle/enqueue", headers=headers, json={"report_type": "daily"}, ) assert response.status_code == 200 assert response.json()["status"] == "queued" finally: monkeypatch.delenv("READ_ONLY_MODE", raising=False) get_settings.cache_clear() def test_lifecycle_report_api_validates_filters_and_report_type() -> None: response = client.get( "/api/v1/reports/personnel-lifecycle", headers=headers, params={"department": "Delivery"}, ) assert response.status_code == 200 assert "metrics" in response.json() invalid = client.post( "/api/v1/reports/lifecycle/enqueue", headers=headers, json={"report_type": "monthly"}, ) assert invalid.status_code == 422 def test_ai_unavailable_sends_notice_without_business_report(monkeypatch) -> None: monkeypatch.setenv("READ_ONLY_MODE", "false") get_settings.cache_clear() monkeypatch.setattr( IntasectSyncService, "sync_all", lambda self, run_code, force_full=False, batch_size=500: {"projects": {"processed": 1}}, ) monkeypatch.setattr( ReportService, "management_lifecycle_report", lambda self, report_type, actor, include_ai: { "title": "Lifecycle", "report_type": report_type, "lines": ["must not be sent"], "content": "must not be sent", "ai_analysis": {"ok": False, "type": "TimeoutError"}, }, ) monkeypatch.setattr( ReportDeliveryService, "push_report", lambda self, *args, **kwargs: pytest.fail("business report must not be sent"), ) monkeypatch.setattr( LifecyclePipelineService, "_notify_ai_unavailable", lambda self, *args, **kwargs: True, ) db = SessionLocal() try: result = LifecyclePipelineService(db).run(ReportType.DAILY, actor="pytest") assert result["status"] == WorkflowStatus.FAILED assert result["ai_unavailable"] is True assert result["notified"] is True finally: db.close() monkeypatch.delenv("READ_ONLY_MODE", raising=False) get_settings.cache_clear() def test_user_rules_are_prioritized_in_ai_context(monkeypatch) -> None: captured: dict = {} class RuleAwareAdapter: provider_name = "rule-aware" def ask(self, prompt, context): captured["context"] = context return {"answer": "followed", "raw": {}} monkeypatch.setenv("AI_MEMORY_AUTO_WRITE_ENABLED", "false") get_settings.cache_clear() monkeypatch.setattr("app.modules.ai_agent.service.get_adapter", lambda: RuleAwareAdapter()) db = SessionLocal() try: rule = AIMemoryService(db).create_rule( content="所有项目风险建议必须注明负责人角色和完成时间", scope="global", subject="company", priority=90, tags=["report"], actor="pytest", ) from app.modules.ai_agent.service import AIService response = AIService(db).ask("分析项目风险", actor="pytest") assert response["answer"] == "followed" assert captured["context"]["user_rules"][0]["rule"] == rule["content"] AIMemoryService(db).update_rule( code=rule["code"], content=None, priority=None, tags=None, enabled=False, actor="pytest", ) assert all(item["code"] != rule["code"] for item in AIMemoryService(db).active_rules()) finally: db.close() monkeypatch.delenv("AI_MEMORY_AUTO_WRITE_ENABLED", raising=False) get_settings.cache_clear() def test_lifecycle_chart_is_uploaded_and_embedded_in_feishu_card(monkeypatch) -> None: chart_data = { "period": "2026-07-11", "projects": {"total": 100, "unarchived": 70, "archived": 30}, "risks": {"overdue_milestones": 5, "overdue_tasks": 8, "open_events": 3}, "people": {"active": 60, "attention": 7, "attendance_mapped": 42}, } png = render_lifecycle_chart(chart_data) assert png.startswith(b"\x89PNG\r\n\x1a\n") captured: dict = {} monkeypatch.setattr( FeishuService, "upload_image", lambda self, image, actor: {"data": {"image_key": "img_test"}}, ) def fake_send_card(self, card, receive_id, receive_id_type, actor): captured["card"] = card return {"code": 0} monkeypatch.setattr(FeishuService, "send_card", fake_send_card) db = SessionLocal() try: ReportDeliveryService(db).push_report( { "title": "Lifecycle", "report_type": "daily", "lines": ["AI analysis"], "content": "AI analysis", "chart_data": chart_data, }, "chat-test", "chat_id", "pytest", ) assert captured["card"]["elements"][0]["tag"] == "img" assert captured["card"]["elements"][0]["img_key"] == "img_test" finally: db.close() def test_user_rule_api_creates_and_disables_rule(monkeypatch) -> None: monkeypatch.setenv("READ_ONLY_MODE", "true") get_settings.cache_clear() try: created = client.post( "/api/v1/ai/rules", headers=headers, json={ "content": "日报分析先说明延期项目,再给出负责人和时限", "scope": "global", "subject": "company", "priority": 80, "tags": ["daily"], }, ) assert created.status_code == 200 code = created.json()["data"]["code"] disabled = client.patch( f"/api/v1/ai/rules/{code}", headers=headers, json={"enabled": False}, ) assert disabled.status_code == 200 assert disabled.json()["data"]["status"] == "archived" finally: monkeypatch.delenv("READ_ONLY_MODE", raising=False) get_settings.cache_clear() def test_intasect_finance_mapping_normalizes_units_and_excludes_sensitive_fields() -> None: seen_at = datetime(2026, 7, 12, 10, 0) project = _project_payload( { "source_id": 99301, "business_code": "FIN-99301", "pro_sn": None, "name": "Finance Project", "mgr_deptid": None, "mgr_deptname": None, "mgr_user_id": None, "mgr_user_name": None, "project_stage": "JD50", "stage_label": "执行中", "archive_flag": "0", "contract_money": "12.34", "project_invest_amount": "56.78", "contract_date": None, "project_done_date": None, "project_information": None, "source_created_at": seen_at, "source_updated_at": seen_at, }, seen_at, ) contract = _contract_payload( { "source_id": 501, "project_id": 99301, "linked_project_id": 99301, "project_del_flag": "0", "contract_type_code": "3", "contract_type_label": "监理合同", "contract_amount": "123400", "contract_date": date(2026, 1, 1), "date_start": date(2026, 1, 1), "date_end": date(2026, 12, 31), "invoice_type_code": "D1", "source_created_at": seen_at, }, seen_at, ) receivable = _contract_receivable_payload( { "source_id": 601, "source_contract_id": 501, "linked_contract_id": 501, "project_id": 99301, "linked_project_id": 99301, "project_del_flag": "0", "category_code": "HTQSH", "category_label": "合同签署后", "planned_amount": "1000", "actual_amount": "200", "planned_date": date(2026, 7, 20), "actual_date": date(2026, 7, 10), "payment_status": "N", "invoice_status": "Y", "source_created_at": seen_at, }, seen_at, ) fund = _project_fund_payload( { "source_id": 701, "project_id": 99301, "linked_project_id": 99301, "project_del_flag": "0", "cost_class": "0", "category_code": "2", "category_label": "投标保证金", "planned_amount": "3000", "approval_status": "1", "confirm_status": "Y", "trade_time": datetime(2026, 7, 11, 9, 0), "source_created_at": seen_at, "source_updated_at": seen_at, }, seen_at, ) assert project["source_contract_amount"] == 123400 assert project["source_project_investment_amount"] == 567800 assert contract["amount"] == 123400 assert receivable["direction"] == CashFlowDirection.INFLOW assert receivable["data_quality_status"] == DataQualityStatus.STATUS_AMOUNT_MISMATCH assert fund["direction"] == CashFlowDirection.OUTFLOW assert fund["actual_amount"] == 3000 for sql in (CONTRACT_SQL, CONTRACT_RECEIVABLE_SQL, PROJECT_FUND_SQL): lowered = sql.lower() assert "bank_account" not in lowered assert "phone" not in lowered assert "payee_mobile" not in lowered def test_project_finance_needs_calculates_horizons_and_funding_range() -> None: db = SessionLocal() project_code = "INTASECT-PROJECT-99302" reference = date(2026, 7, 12) try: project = Project( code=project_code, display_code="FIN-99302", name="Funding Needs Project", status=StatusValue.RUNNING, source_system="legacy_mysql", external_id="99302", source_archived=False, is_active=True, ) db.add(project) db.add( ProjectContract( code="INTASECT-CONTRACT-99302", project_code=project_code, amount=10000, data_quality_status=DataQualityStatus.VALID, source_system="legacy_mysql", external_id="99302", is_active=True, ) ) db.add_all( [ ProjectCashFlow( code="INTASECT-RECEIVABLE-9930201", project_code=project_code, contract_code="INTASECT-CONTRACT-99302", flow_type=CashFlowType.CONTRACT_RECEIVABLE, direction=CashFlowDirection.INFLOW, planned_amount=1000, actual_amount=200, planned_date=reference + timedelta(days=8), payment_status="N", data_quality_status=DataQualityStatus.STATUS_AMOUNT_MISMATCH, source_system="legacy_mysql", external_id="receivable:9930201", is_active=True, ), ProjectCashFlow( code="INTASECT-RECEIVABLE-9930202", project_code=project_code, contract_code="INTASECT-CONTRACT-99302", flow_type=CashFlowType.CONTRACT_RECEIVABLE, direction=CashFlowDirection.INFLOW, planned_amount=500, actual_amount=0, planned_date=reference - timedelta(days=1), payment_status="N", data_quality_status=DataQualityStatus.VALID, source_system="legacy_mysql", external_id="receivable:9930202", is_active=True, ), ProjectCashFlow( code="INTASECT-FUND-9930203", project_code=project_code, flow_type=CashFlowType.PROJECT_FUND, direction=CashFlowDirection.OUTFLOW, planned_amount=1500, approval_status="1", confirmation_status="N", data_quality_status=DataQualityStatus.VALID, source_system="legacy_mysql", external_id="fund:9930203", is_active=True, ), ProjectCashFlow( code="INTASECT-FUND-9930204", project_code=project_code, flow_type=CashFlowType.PROJECT_FUND, direction=CashFlowDirection.OUTFLOW, planned_amount=300, actual_amount=300, approval_status="1", confirmation_status="Y", data_quality_status=DataQualityStatus.VALID, source_system="legacy_mysql", external_id="fund:9930204", is_active=True, ), ] ) db.commit() report = ReportService(db).project_finance_needs_report( project_code=project_code, as_of=reference, ) item = report["items"][0] assert item["actual_receipt"] == 200 assert item["overdue_receivable"] == 500 assert item["receivable_due"]["7"] == 0 assert item["receivable_due"]["30"] == 800 assert item["funding_need"]["7"] == {"lower": 1500, "upper": 1500} assert item["funding_need"]["30"] == {"lower": 700, "upper": 1500} assert report["summary"]["confirmed_outflow"] == 300 assert report["disclaimer"].startswith("项目资金安排需求不包含公司账户余额") assert "不代表真实融资缺口" in report["content"] finally: db.close() def test_project_finance_needs_does_not_render_missing_data_as_zero() -> None: db = SessionLocal() project_code = "INTASECT-PROJECT-99304" try: db.add( Project( code=project_code, name="No Finance Data Project", status=StatusValue.RUNNING, source_system="legacy_mysql", external_id="99304", source_archived=False, is_active=True, ) ) db.commit() report = ReportService(db).project_finance_needs_report(project_code=project_code) assert report["summary"]["data_available"] is False assert report["summary"]["contract_revenue"] is None assert report["items"][0]["pending_outflow"] is None assert "金额不按零值解释" in report["content"] assert report["finance_chart_data"] is None finally: db.close() def test_finance_sync_soft_deactivates_missing_contract() -> None: row = { "source_key": "99303", "source_id": 99303, "project_id": 99303, "linked_project_id": 99303, "project_del_flag": "0", "contract_type_code": "3", "contract_type_label": "监理合同", "contract_amount": 5000, "contract_date": date(2026, 1, 1), "date_start": None, "date_end": None, "invoice_type_code": "D1", "source_created_at": datetime(2026, 1, 1), } class FakeSource: def __init__(self, rows): self.rows = rows def fetch_page(self, dataset, after_key, watermark_at, limit): assert dataset == "contracts" assert limit == 500 return self.rows if not after_key else [] db = SessionLocal() try: IntasectSyncService(db, FakeSource([row])).sync_dataset("contracts", "FIN-RUN-1") contract = db.execute( select(ProjectContract).where(ProjectContract.external_id == "99303") ).scalar_one() assert contract.is_active is True IntasectSyncService(db, FakeSource([])).sync_dataset("contracts", "FIN-RUN-2") db.refresh(contract) assert contract.is_active is False finally: db.close() def test_finance_api_and_feishu_command_fail_closed_when_ai_unavailable(monkeypatch) -> None: monkeypatch.setenv("FINANCE_NEEDS_ENABLED", "true") get_settings.cache_clear() try: response = client.get( "/api/v1/reports/project-finance-needs", headers=headers, params={"project_code": "INTASECT-PROJECT-99302", "as_of": "2026-07-12"}, ) assert response.status_code == 200 assert response.json()["currency"] == "CNY" db = SessionLocal() try: result = FeishuCommandService(db).handle_text( "项目资金 FIN-99302", actor="ou_finance_test", auto_reply=False, ) assert result["command"] == "project_finance" assert result["reply_type"] == "text" assert "AI 当前不可用" in result["content"] assert "Funding Needs Project" not in result["content"] finally: db.close() finally: monkeypatch.delenv("FINANCE_NEEDS_ENABLED", raising=False) get_settings.cache_clear() def test_feishu_finance_command_returns_ai_analysis_when_available(monkeypatch) -> None: class FinanceAdapter: provider_name = "finance-test" def ask(self, prompt, context): assert context["summary"]["data_available"] is True assert "user_rules" in context assert "owner" not in json.dumps(context["attention"], ensure_ascii=False) return {"answer": "优先安排关键项目资金,并由财务负责人复核。", "raw": {}} monkeypatch.setenv("FINANCE_NEEDS_ENABLED", "true") monkeypatch.setenv("AI_MEMORY_AUTO_WRITE_ENABLED", "false") get_settings.cache_clear() monkeypatch.setattr("app.modules.ai_agent.service.get_adapter", lambda: FinanceAdapter()) db = SessionLocal() rule = None try: rule = AIMemoryService(db).create_rule( content="资金建议必须要求人工确认", scope="global", subject="company", priority=95, tags=["finance"], actor="pytest", ) result = FeishuCommandService(db).handle_text( "项目资金 FIN-99302", actor="ou_finance_test", auto_reply=False, ) assert result["command"] == "project_finance" assert result["reply_type"] == "card" assert "优先安排关键项目资金" in result["content"] finally: if rule is not None: AIMemoryService(db).update_rule( code=rule["code"], content=None, priority=None, tags=None, enabled=False, actor="pytest", ) db.close() monkeypatch.delenv("FINANCE_NEEDS_ENABLED", raising=False) monkeypatch.delenv("AI_MEMORY_AUTO_WRITE_ENABLED", raising=False) get_settings.cache_clear() def test_lifecycle_chart_renders_finance_section() -> None: png = render_lifecycle_chart( { "period": "2026-07-12", "projects": {"total": 1, "unarchived": 1, "archived": 0}, "risks": {}, "people": {}, "finance": { "cashflows": { "confirmed_inflow": 1000, "confirmed_outflow": 300, "pending_outflow": 700, }, "top_projects": [{"name": "Project A", "amount": 700}], }, } ) assert png.startswith(b"\x89PNG\r\n\x1a\n") def test_market_sync_and_overview_use_deterministic_breadth() -> None: class FakeMarketProvider: def query(self, api_name, params, fields): if api_name == "trade_cal": return [{"exchange": "SSE", "cal_date": "20260710", "is_open": "1"}] if api_name == "stock_basic": return [ { "ts_code": "600101.SH", "name": "Alpha", "exchange": "SSE", "industry": "科技", "list_date": "20200101", }, { "ts_code": "000101.SZ", "name": "Beta", "exchange": "SZSE", "industry": "消费", "list_date": "20200101", }, ] if api_name == "daily": return [ { "ts_code": "600101.SH", "trade_date": "20260710", "open": 10, "high": 11, "low": 9, "close": 11, "pre_close": 10, "pct_chg": 10, "vol": 100, "amount": 200, }, { "ts_code": "000101.SZ", "trade_date": "20260710", "open": 10, "high": 10, "low": 8, "close": 9, "pre_close": 10, "pct_chg": -10, "vol": 100, "amount": 300, }, ] if api_name == "daily_basic": return [{"ts_code": "600101.SH", "pe_ttm": 20, "pb": 2, "total_mv": 10000}] if api_name == "index_daily": return [ { "ts_code": params["ts_code"], "trade_date": "20260710", "open": 100, "high": 102, "low": 99, "close": 101, "pre_close": 100, "pct_chg": 1, "vol": 10, "amount": 20, } ] return [] db = SessionLocal() try: result = MarketService(db, FakeMarketProvider()).sync_daily(date(2026, 7, 10)) assert result == {"instruments": 2, "quotes": 6, "valuations": 1} report = MarketService(db).market_overview(date(2026, 7, 10)) assert report["data_available"] is True assert report["metrics"]["advances"] == 1 assert report["metrics"]["declines"] == 1 assert report["metrics"]["limit_up"] == 1 assert report["metrics"]["limit_down"] == 1 assert report["metrics"]["turnover_cny"] == 500000 finally: db.close() def test_stock_analysis_calculates_returns_risk_and_chart() -> None: db = SessionLocal() symbol = "600102.SH" try: db.add(MarketInstrument(symbol=symbol, name="Gamma", exchange="SSE", industry="金融")) for index in range(70): db.add( MarketDailyQuote( symbol=symbol, trade_date=date(2026, 4, 1) + timedelta(days=index), close_price=10 + index / 10, pct_change=1, pe=15, pb=1.5, ) ) db.add( MarketFinancialMetric( symbol=symbol, period_end=date(2026, 3, 31), revenue_yoy=12, net_profit_yoy=8, roe=10, debt_to_assets=40, operating_cashflow_yoy=None, ) ) db.commit() report = MarketService(db).stock_analysis("600102") assert report["metrics"]["return_20d"] > 0 assert report["metrics"]["max_drawdown"] == 0 assert report["metrics"]["financial"]["revenue_yoy"] == 12 assert report["metrics"]["financial"]["operating_cashflow_yoy"] is None assert render_market_chart(report).startswith(b"\x89PNG\r\n\x1a\n") assert normalize_symbol("000001") == "000001.SZ" finally: db.close() def test_market_api_and_feishu_fail_closed_without_ai(monkeypatch) -> None: monkeypatch.setenv("MARKET_ANALYSIS_ENABLED", "true") get_settings.cache_clear() try: response = client.get( "/api/v1/market/overview", headers=headers, params={"trade_date": "2026-07-10"}, ) assert response.status_code == 200 assert response.json()["data_available"] is True db = SessionLocal() try: result = FeishuCommandService(db).handle_text( "市场分析", actor="ou_market", auto_reply=False ) assert result["command"] == "market_overview" assert result["reply_type"] == "text" assert "AI 当前不可用" in result["content"] assert "领涨行业" not in result["content"] industry = FeishuCommandService(db).handle_text( "行业分析 科技", actor="ou_market", auto_reply=False ) assert "Alpha" in industry["content"] comparison = FeishuCommandService(db).handle_text( "股票对比 600101 000101", actor="ou_market", auto_reply=False ) assert "Alpha" in comparison["content"] assert "Beta" in comparison["content"] finally: db.close() finally: monkeypatch.delenv("MARKET_ANALYSIS_ENABLED", raising=False) get_settings.cache_clear() def test_market_closed_day_skips_quote_collection() -> None: class ClosedProvider: def query(self, api_name, params, fields): assert api_name == "trade_cal" return [{"is_open": "0"}] db = SessionLocal() try: result = MarketService(db, ClosedProvider()).sync_daily(date(2026, 7, 11)) assert result["market_closed"] == 1 assert result["quotes"] == 0 finally: db.close() def test_market_scheduler_registers_close_and_weekly_jobs(monkeypatch) -> None: monkeypatch.setenv("MARKET_ANALYSIS_ENABLED", "true") monkeypatch.setenv("READ_ONLY_MODE", "true") get_settings.cache_clear() try: scheduler = create_scheduler() job_ids = {job.id for job in scheduler.get_jobs()} assert "market_premarket_analysis" in job_ids assert "market_close_analysis" in job_ids assert "market_weekly_analysis" in job_ids finally: monkeypatch.delenv("MARKET_ANALYSIS_ENABLED", raising=False) monkeypatch.delenv("READ_ONLY_MODE", raising=False) get_settings.cache_clear() def test_market_ai_exception_is_reported_without_business_fallback(monkeypatch) -> None: class BrokenAdapter: provider_name = "broken" def ask(self, prompt, context): raise TimeoutError("model timeout") monkeypatch.setenv("MARKET_ANALYSIS_ENABLED", "true") get_settings.cache_clear() monkeypatch.setattr("app.modules.ai_agent.service.get_adapter", lambda: BrokenAdapter()) db = SessionLocal() try: result = FeishuCommandService(db).handle_text( "市场分析", actor="ou_market", auto_reply=False ) assert result["reply_type"] == "text" assert "AI 当前不可用" in result["content"] assert "上涨" not in result["content"] finally: db.close() monkeypatch.delenv("MARKET_ANALYSIS_ENABLED", raising=False) get_settings.cache_clear() def test_tushare_http_failure_has_stable_error(monkeypatch) -> None: import httpx monkeypatch.setenv("MARKET_DATA_TOKEN", "test-token") get_settings.cache_clear() monkeypatch.setattr( "app.modules.market.service.httpx.post", lambda *args, **kwargs: (_ for _ in ()).throw(httpx.ConnectTimeout("timeout")), ) try: with pytest.raises(HTTPException) as exc_info: TushareClient().query("trade_cal", {}, ["is_open"]) assert exc_info.value.status_code == 503 assert exc_info.value.detail == "Market data provider unavailable" finally: monkeypatch.delenv("MARKET_DATA_TOKEN", raising=False) get_settings.cache_clear() def test_market_macro_and_announcement_sync_store_only_curated_fields() -> None: class Provider: def query(self, api_name, params, fields): if api_name == "shibor": return [{"date": "20260710", "on": "1.55"}] if api_name == "cn_cpi": return [{"month": "202606", "nt_yoy": "0.4"}] if api_name == "cn_gdp": return [{"quarter": "2026Q2", "gdp_yoy": "5.1"}] if api_name == "cn_m": return [{"month": "202606", "m2_yoy": "8.3"}] if api_name == "anns_d" and params["ann_date"] == "20260710": return [ { "ann_date": "20260710", "ts_code": "600103.SH", "title": "年度报告披露提示", "url": "https://example.invalid/report.pdf", "rec_time": "2026-07-10 08:30:00", "content": "正文不得保存", } ] return [] db = SessionLocal() try: service = MarketService(db, Provider()) result = service.sync_macro(date(2026, 7, 10)) assert result == {"shibor": 1, "cn_cpi": 1, "cn_gdp": 1, "cn_m": 1} assert service.sync_announcements(date(2026, 7, 10), date(2026, 7, 10)) == 1 codes = set(db.execute(select(MarketMacroIndicator.code)).scalars()) assert {"SHIBOR_ON", "CPI_YOY", "GDP_YOY", "M2_YOY"}.issubset(codes) announcement = db.execute( select(MarketAnnouncement).where(MarketAnnouncement.symbol == "600103.SH") ).scalar_one() assert announcement.title == "年度报告披露提示" assert not hasattr(announcement, "content") assert service.sync_announcements(date(2026, 7, 10), date(2026, 7, 10)) == 1 count = len( list( db.execute( select(MarketAnnouncement).where( MarketAnnouncement.symbol == "600103.SH" ) ).scalars() ) ) assert count == 1 finally: db.close() def test_market_pipeline_is_idempotent_and_requires_complete_ai_chart_delivery( monkeypatch, ) -> None: class FakeMarket: def sync_daily(self, target): return {"quotes": 2} def sync_macro(self, target): return {"shibor": 1} def sync_announcements(self, start, end): return 1 def sync_watchlist_financials(self): return {} def market_overview(self, target=None, include_ai=False, actor="api"): return { "title": "市场收盘分析", "data_available": True, "metrics": {"advances": 1, "declines": 1, "flat": 0}, "chart_data": {"advances": 1, "declines": 1, "flat": 0}, "lines": ["- AI 已完成分析"], "content": "AI 已完成分析", "ai_analysis": {"ok": True, "answer": "分析完成"}, } monkeypatch.setenv("FEISHU_APP_ID", "test-app") monkeypatch.setenv("FEISHU_APP_SECRET", "test-secret") monkeypatch.setenv("FEISHU_DEFAULT_CHAT_ID", "oc_market") monkeypatch.setenv("READ_ONLY_MODE", "true") get_settings.cache_clear() monkeypatch.setattr( "app.modules.feishu.service.FeishuService.upload_image", lambda self, image, actor="system": {"data": {"image_key": "img-market"}}, ) monkeypatch.setattr( "app.modules.feishu.service.FeishuService.send_card", lambda self, card, receive_id=None, receive_id_type="chat_id", actor="system": { "code": 0 }, ) db = SessionLocal() try: pipeline = MarketPipelineService(db, FakeMarket()) first = pipeline.run("close", date(2030, 7, 12), actor="pytest") second = pipeline.run("close", date(2030, 7, 12), actor="pytest") assert first["status"] == WorkflowStatus.COMPLETED assert second["deduplicated"] is True assert second["workflow_code"] == first["workflow_code"] finally: db.close() monkeypatch.delenv("FEISHU_APP_ID", raising=False) monkeypatch.delenv("FEISHU_APP_SECRET", raising=False) monkeypatch.delenv("FEISHU_DEFAULT_CHAT_ID", raising=False) monkeypatch.delenv("READ_ONLY_MODE", raising=False) get_settings.cache_clear() def test_market_pipeline_fails_closed_when_ai_is_unavailable(monkeypatch) -> None: class FakeMarket: def sync_daily(self, target): return {"quotes": 1} def sync_macro(self, target): return {} def sync_announcements(self, start, end): return 0 def sync_watchlist_financials(self): return {} def market_overview(self, target=None, include_ai=False, actor="api"): return { "title": "市场分析", "data_available": True, "metrics": {"advances": 1}, "chart_data": {"advances": 1}, "lines": ["- 不应推送"], "content": "不应推送", "ai_analysis": {"ok": False, "error": "timeout"}, } monkeypatch.setenv("READ_ONLY_MODE", "false") get_settings.cache_clear() db = SessionLocal() try: result = MarketPipelineService(db, FakeMarket()).run( "close", date(2030, 7, 15), actor="pytest" ) assert result["status"] == WorkflowStatus.FAILED assert result["reason"] == "ai_unavailable" workflow = db.execute( select(WorkflowInstance).where(WorkflowInstance.code == result["workflow_code"]) ).scalar_one() assert workflow.current_step == "ai_unavailable" finally: db.close() monkeypatch.delenv("READ_ONLY_MODE", raising=False) get_settings.cache_clear() def test_market_weekly_overview_uses_five_latest_trading_days() -> None: db = SessionLocal() try: symbols = (("600104.SH", "Delta", "科技", 10, 12), ("000104.SZ", "Epsilon", "消费", 10, 8)) days = [date(2031, 7, 7) + timedelta(days=index) for index in range(5)] for symbol, name, industry, first, last in symbols: db.add(MarketInstrument(symbol=symbol, name=name, industry=industry)) for index, day in enumerate(days): close = first + (last - first) * index / 4 db.add(MarketDailyQuote(symbol=symbol, trade_date=day, close_price=close)) db.commit() report = MarketService(db).weekly_overview(date(2031, 7, 11)) assert report["data_available"] is True assert report["metrics"]["trading_days"] == 5 assert report["metrics"]["advances"] == 1 assert report["metrics"]["declines"] == 1 assert report["metrics"]["period_start"] == "2031-07-07" assert report["metrics"]["period_end"] == "2031-07-11" finally: db.close() def test_feishu_market_rules_are_scoped_to_market(monkeypatch) -> None: monkeypatch.setenv("MARKET_ANALYSIS_ENABLED", "true") monkeypatch.setenv("READ_ONLY_MODE", "false") get_settings.cache_clear() db = SessionLocal() try: created = FeishuCommandService(db).handle_text( "学习市场规则 90:市场结论必须列出数据日期", actor="ou_market_rule", auto_reply=False, ) assert "market / market" in created["content"] listed = FeishuCommandService(db).handle_text( "查看市场规则", actor="ou_market_rule", auto_reply=False ) assert "市场结论必须列出数据日期" in listed["content"] assert "market/market" in listed["content"] finally: db.close() monkeypatch.delenv("MARKET_ANALYSIS_ENABLED", raising=False) monkeypatch.delenv("READ_ONLY_MODE", raising=False) get_settings.cache_clear() def test_read_only_mode_allows_local_rules_and_watchlist(monkeypatch) -> None: monkeypatch.setenv("READ_ONLY_MODE", "true") monkeypatch.setenv("MARKET_ANALYSIS_ENABLED", "true") get_settings.cache_clear() db = SessionLocal() try: rules = FeishuCommandService(db).handle_text( "学习市场规则:这条规则应写入本地状态", actor="ou_read_only", auto_reply=False, ) watchlist = FeishuCommandService(db).handle_text( "加入自选 600105", actor="ou_read_only", auto_reply=False ) assert "规则已学习" in rules["content"] assert "已加入自选:600105.SH" in watchlist["content"] assert db.execute( select(AIMemoryEntry).where(AIMemoryEntry.content == "这条规则应写入本地状态") ).scalar_one_or_none() is not None finally: db.close() monkeypatch.delenv("READ_ONLY_MODE", raising=False) monkeypatch.delenv("MARKET_ANALYSIS_ENABLED", raising=False) get_settings.cache_clear()