Files
company-ai-platform/tests/test_smoke.py
JiuContinent dc8605ce3f ```
refactor(core): 重构核心模块结构并更新导入路径

- 将配置相关的设置从 app.core.config 移除
- 将常量定义从 app.core.constants 移除
- 将数据库相关功能从 app.core.database 移除
- 将基础数据库模型从 app.core.db_base 移除
- 将敏感信息掩码功能从 app.core.masking 移除
- 将中间件定义从 app.core.middleware 移除
- 将操作保护功能从 app.core.operation_guard 移除
- 将分页工具从 app.core.pagination 移除
- 将请求上下文管理从 app.core.request_context 移除
- 将调度器功能从 app.core.scheduler 移除
- 将安全认证逻辑从 app.core.security 移除
- 将任务队列相关功能从 app.core.task_queue 移除
- 将时间工具从 app.core.time 移除
- 更新 alembic 配置中的 Base 模型导入路径
- 更新各模块中对重构后组件的引用路径
```
2026-07-09 17:41:16 +08:00

1055 lines
34 KiB
Python

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()