Files
company-ai-platform/tests/test_smoke.py
JiuContinent 19e59e83cc ```
feat: 添加数据库迁移脚本并更新Dockerfile配置

- 在Dockerfile中添加alembic配置文件和目录的复制指令
- 更新alembic/env.py注册新的模块模型:events、workflows、writebacks
- 生成完整的初始数据库schema迁移脚本,包含以下表:
  - approval_requests, attendance_records, audit_logs, domain_events
  - expenses, feishu_event_receipts, fund_accounts, legacy_sync_runs
  - official_writeback_runs, performance_metrics, policies, procurements
  - projects, report_push_runs, risk_event_actions, risk_events
  - standards, suppliers, work_reports, work_tasks, workflow_actions
  - workflow_instances等21个数据表结构定义
- 在API路由器中添加新模块的路由:events、workflows、writebacks、observability
```
2026-07-08 14:08:03 +08:00

1332 lines
44 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 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["APPROVAL_API_KEY"] = "approval-key"
os.environ["APPROVAL_API_ACTOR"] = "approval-manager"
os.environ["FEISHU_APP_ID"] = ""
os.environ["FEISHU_APP_SECRET"] = ""
os.environ["FEISHU_VERIFICATION_TOKEN"] = "test-feishu-token"
os.environ["FEISHU_APPROVAL_APPROVER_IDS"] = json.dumps(["ou_card_approver"])
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, engine
from app.core.pagination import bounded_limit, bounded_offset
from app.core.security import require_api_key, require_approval_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.events.constants import (
EventAggregateType,
EventPayloadKey,
EventSource,
EventStatus,
EventType,
)
from app.modules.events.service import EventService
from app.modules.feishu.constants import (
FEISHU_APPROVAL_APPROVER_IDS_REQUIRED,
FEISHU_APPROVER_NOT_ALLOWED,
)
from app.modules.legacy_mysql.service import LegacyMySQLService
from app.modules.reports.constants import (
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
from app.modules.writebacks.constants import WritebackStatus
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"}
approval_headers = {"X-API-Key": "test-key", "X-Approval-API-Key": "approval-key"}
def approve_change(
domain: str,
action: str,
payload: dict,
record_id: str | int | None = None,
) -> str:
request_payload: dict[str, object] = {
"domain": domain,
"action": action,
"reason": "pytest approval",
"payload": payload,
}
if record_id is not None:
request_payload["record_id"] = str(record_id)
response = client.post("/api/v1/approvals", headers=headers, json=request_payload)
assert response.status_code == 200
ticket_id = response.json()["ticket_id"]
approve_response = client.post(
f"/api/v1/approvals/{ticket_id}/approve",
headers=approval_headers,
json={"comment": "pytest approved"},
)
assert approve_response.status_code == 200
return ticket_id
def create_business_record(domain: str, data: dict, actor: str = "pytest"):
ticket_id = approve_change(domain, f"create:{domain}", data)
return client.post(
f"/api/v1/business/{domain}",
headers=headers,
json={"actor": actor, "approval_ticket_id": ticket_id, "data": data},
)
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:
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
assert "metrics" in response.json()
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_risk_action_creates_workflow() -> 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 == 200
response = client.get(
"/api/v1/workflows",
headers=headers,
params={"workflow_type": WorkflowType.RISK_EVENT_REVIEW},
)
assert response.status_code == 200
workflows = response.json()["items"]
assert any(
item["aggregate_id"] == str(risk_id) and item["status"] == WorkflowStatus.RUNNING
for item in workflows
)
def test_v3_writeback_disabled_requires_approval_without_consuming_ticket() -> 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 == 200
writeback_code = response.json()["data"]["code"]
response = client.post(
f"/api/v1/writebacks/{writeback_code}/submit",
headers=headers,
json={},
)
assert response.status_code == 409
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 == 200
ticket_id = response.json()["ticket_id"]
response = client.post(
f"/api/v1/approvals/{ticket_id}/approve",
headers=approval_headers,
json={"comment": "approved for disabled adapter test"},
)
assert response.status_code == 200
response = client.post(
f"/api/v1/writebacks/{writeback_code}/submit",
headers=headers,
json={"approval_ticket_id": ticket_id},
)
assert response.status_code == 200
assert response.json()["data"]["status"] == WritebackStatus.DISABLED
response = client.get(f"/api/v1/approvals/{ticket_id}", headers=approval_headers)
assert response.status_code == 200
assert response.json()["status"] == "approved"
response = client.get(
"/api/v1/workflows",
headers=headers,
params={"workflow_type": WorkflowType.OFFICIAL_WRITEBACK},
)
assert response.status_code == 200
workflows = response.json()["items"]
assert any(
item["aggregate_id"] == writeback_code and item["status"] == WorkflowStatus.BLOCKED
for item in workflows
)
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("APPROVAL_API_KEY", "")
get_settings.cache_clear()
with pytest.raises(HTTPException) as approval_exc_info:
require_approval_api_key("approval-key")
assert approval_exc_info.value.status_code == 503
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")
monkeypatch.setenv("APPROVAL_API_KEY", "approval-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
assert response.json()["data"]["amount"] == "[MASKED]"
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_writes_require_approval_and_reject_read_only_fields() -> None:
blocked_response = client.post(
"/api/v1/business/projects",
headers=headers,
json={
"data": {
"code": "P-APPROVAL-BLOCKED",
"name": "Blocked project",
},
},
)
assert blocked_response.status_code == 409
readonly_payload = {
"code": "P-READONLY-001",
"name": "Readonly project",
"created_at": "2026-07-08T00:00:00",
}
ticket_id = approve_change("projects", "create:projects", readonly_payload)
readonly_response = client.post(
"/api/v1/business/projects",
headers=headers,
json={
"approval_ticket_id": ticket_id,
"data": readonly_payload,
},
)
assert readonly_response.status_code == 422
assert readonly_response.json()["detail"] == "Field 'created_at' is read-only"
def test_approval_gate_for_high_risk_update() -> None:
create_payload = {
"code": "FUND-SMOKE-001",
"name": "Main Account",
"current_balance": 1000,
"safety_line": 500,
}
blocked_create_response = client.post(
"/api/v1/business/fund-accounts",
headers=headers,
json={
"actor": "spoofed-user",
"data": {
"code": "FUND-SMOKE-BLOCKED",
"name": "Blocked Account",
},
},
)
assert blocked_create_response.status_code == 409
create_approval_response = client.post(
"/api/v1/approvals",
headers=headers,
json={
"domain": "fund-accounts",
"action": "create:fund-accounts",
"applicant": "spoofed-user",
"reason": "Smoke test account creation",
"payload": create_payload,
},
)
assert create_approval_response.status_code == 200
assert create_approval_response.json()["applicant"] == "api"
create_ticket_id = create_approval_response.json()["ticket_id"]
approval_list_without_approval_key_response = client.get("/api/v1/approvals", headers=headers)
assert approval_list_without_approval_key_response.status_code == 401
approval_detail_without_approval_key_response = client.get(
f"/api/v1/approvals/{create_ticket_id}",
headers=headers,
)
assert approval_detail_without_approval_key_response.status_code == 401
approval_detail_response = client.get(
f"/api/v1/approvals/{create_ticket_id}",
headers=approval_headers,
)
assert approval_detail_response.status_code == 200
approve_create_response = client.post(
f"/api/v1/approvals/{create_ticket_id}/approve",
headers=approval_headers,
json={"comment": "ok"},
)
assert approve_create_response.status_code == 200
assert approve_create_response.json()["approver"] == "approval-manager"
create_response = client.post(
"/api/v1/business/fund-accounts",
headers=headers,
json={
"actor": "spoofed-user",
"approval_ticket_id": create_ticket_id,
"data": create_payload,
},
)
assert create_response.status_code == 200
record_id = create_response.json()["data"]["id"]
reuse_create_response = client.post(
"/api/v1/business/fund-accounts",
headers=headers,
json={
"approval_ticket_id": create_ticket_id,
"data": {
"code": "FUND-SMOKE-REUSE",
"name": "Reuse Account",
},
},
)
assert reuse_create_response.status_code == 403
blocked_response = client.patch(
f"/api/v1/business/fund-accounts/{record_id}",
headers=headers,
json={"actor": "spoofed-user", "data": {"current_balance": 100}},
)
assert blocked_response.status_code == 409
approval_response = client.post(
"/api/v1/approvals",
headers=headers,
json={
"domain": "fund-accounts",
"record_id": str(record_id),
"action": "update:fund-accounts",
"applicant": "spoofed-user",
"reason": "Smoke test balance adjustment",
"payload": {"current_balance": 100},
},
)
assert approval_response.status_code == 200
ticket_id = approval_response.json()["ticket_id"]
pending_response = client.patch(
f"/api/v1/business/fund-accounts/{record_id}",
headers=headers,
json={
"actor": "spoofed-user",
"approval_ticket_id": ticket_id,
"data": {"current_balance": 100},
},
)
assert pending_response.status_code == 403
approve_response = client.post(
f"/api/v1/approvals/{ticket_id}/approve",
headers=approval_headers,
json={"approver": "spoofed-manager", "comment": "ok"},
)
assert approve_response.status_code == 200
assert approve_response.json()["status"] == "approved"
assert approve_response.json()["approver"] == "approval-manager"
mismatch_response = client.patch(
f"/api/v1/business/fund-accounts/{record_id}",
headers=headers,
json={
"actor": "spoofed-user",
"approval_ticket_id": ticket_id,
"data": {"current_balance": 101},
},
)
assert mismatch_response.status_code == 403
update_response = client.patch(
f"/api/v1/business/fund-accounts/{record_id}",
headers=headers,
json={
"actor": "spoofed-user",
"approval_ticket_id": ticket_id,
"data": {"current_balance": 100},
},
)
assert update_response.status_code == 200
assert update_response.json()["data"]["current_balance"] == 100.0
reuse_update_response = client.patch(
f"/api/v1/business/fund-accounts/{record_id}",
headers=headers,
json={
"actor": "spoofed-user",
"approval_ticket_id": ticket_id,
"data": {"current_balance": 100},
},
)
assert reuse_update_response.status_code == 403
def test_feishu_approval_card_action_approves_ticket() -> 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 == 200
ticket_id = approval_response.json()["ticket_id"]
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": ticket_id,
"decision": "approve",
"comment": "approved from card",
}
},
},
)
assert callback_response.status_code == 200
assert callback_response.json()["result"]["status"] == "approved"
assert callback_response.json()["result"]["approver"] == "ou_card_approver"
duplicate_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": ticket_id,
"decision": "approve",
"comment": "approved from card",
}
},
},
)
assert duplicate_response.status_code == 200
assert duplicate_response.json()["duplicate"] is True
assert duplicate_response.json()["result"]["status"] == "approved"
def test_feishu_approval_card_action_requires_approver_allowlist(monkeypatch) -> None:
monkeypatch.setenv("FEISHU_APPROVAL_APPROVER_IDS", "")
get_settings.cache_clear()
try:
approval_response = client.post(
"/api/v1/approvals",
headers=headers,
json={
"domain": "fund-accounts",
"record_id": "feishu-card-missing-allowlist",
"action": "update:fund-accounts",
"reason": "Card action missing allowlist test",
"payload": {"current_balance": 301},
},
)
assert approval_response.status_code == 200
ticket_id = approval_response.json()["ticket_id"]
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": ticket_id,
"decision": "approve",
}
},
},
)
assert callback_response.status_code == 503
assert callback_response.json()["detail"] == FEISHU_APPROVAL_APPROVER_IDS_REQUIRED
finally:
monkeypatch.setenv("FEISHU_APPROVAL_APPROVER_IDS", "ou_card_approver")
get_settings.cache_clear()
def test_feishu_approval_card_action_rejects_unlisted_approver() -> None:
approval_response = client.post(
"/api/v1/approvals",
headers=headers,
json={
"domain": "fund-accounts",
"record_id": "feishu-card-unlisted-approver",
"action": "update:fund-accounts",
"reason": "Card action allowlist test",
"payload": {"current_balance": 302},
},
)
assert approval_response.status_code == 200
ticket_id = approval_response.json()["ticket_id"]
callback_response = client.post(
"/api/v1/integrations/feishu/approval-card-action",
json={
"token": "test-feishu-token",
"operator": {"operator_id": {"open_id": "ou_not_allowed"}},
"action": {
"value": {
"ticket_id": ticket_id,
"decision": "approve",
}
},
},
)
assert callback_response.status_code == 403
assert callback_response.json()["detail"] == FEISHU_APPROVER_NOT_ALLOWED
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"]["report_type"] == ReportType.DAILY
risk_response = client.post(
"/api/v1/risks/events/generate?actor=pytest",
headers=headers,
)
assert risk_response.status_code == 200
assert risk_response.json()["created"] >= 1
enqueue_response = client.post("/api/v1/risks/events/enqueue", headers=headers)
assert enqueue_response.status_code == 200
assert enqueue_response.json()["queued"] is False
assert "result" in enqueue_response.json()
events_response = client.get("/api/v1/risks/events?status=open", headers=headers)
assert events_response.status_code == 200
assert any(item["risk_type"] == "overdue_task" for item in events_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_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_sync_creates_and_updates_internal_tasks(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,
)
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()["created"] == 1
assert response.json()["items"][0]["task"]["external_id"] == "9001"
rows[0]["task_name"] = "Legacy task one updated"
second_response = client.post(
"/api/v1/integrations/mysql/tasks/sync",
headers=headers,
json={
"dry_run": False,
"field_map": {"title": "task_name"},
},
)
assert second_response.status_code == 200
assert second_response.json()["updated"] == 1
tasks_response = client.get("/api/v1/business/tasks", headers=headers)
assert tasks_response.status_code == 200
item = next(
item for item in tasks_response.json()["items"] if item["external_id"] == "9001"
)
assert item["title"] == "Legacy task one updated"
def test_risk_event_workflow_records_actions() -> 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 == 200
assert assign_response.json()["risk_event"]["assigned_to"] == "risk-owner"
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
assert resolve_response.json()["risk_event"]["status"] == "resolved"
blocked_close_response = client.post(
f"/api/v1/risks/events/{event_id}/close",
headers=headers,
json={
"closed_reason": "verified",
"review_summary": "handled",
},
)
assert blocked_close_response.status_code == 409
close_ticket_response = client.post(
"/api/v1/approvals",
headers=headers,
json={
"domain": "risk-events",
"record_id": str(event_id),
"action": "update:risk-events",
"reason": "Close risk event",
"payload": {
"status": "closed",
"closed_reason": "verified",
"review_summary": "handled",
},
},
)
assert close_ticket_response.status_code == 200
close_ticket_id = close_ticket_response.json()["ticket_id"]
approve_close_response = client.post(
f"/api/v1/approvals/{close_ticket_id}/approve",
headers=approval_headers,
json={"comment": "risk close approved"},
)
assert approve_close_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",
"approval_ticket_id": close_ticket_id,
},
)
assert close_response.status_code == 200
assert close_response.json()["risk_event"]["status"] == "closed"
assert close_response.json()["risk_event"]["closed_reason"] == "verified"
blocked_reopen_response = client.post(
f"/api/v1/risks/events/{event_id}/reopen",
headers=headers,
json={"comment": "recheck"},
)
assert blocked_reopen_response.status_code == 409
reopen_ticket_response = client.post(
"/api/v1/approvals",
headers=headers,
json={
"domain": "risk-events",
"record_id": str(event_id),
"action": "update:risk-events",
"reason": "Reopen risk event",
"payload": {"status": "open", "comment": "recheck"},
},
)
assert reopen_ticket_response.status_code == 200
reopen_ticket_id = reopen_ticket_response.json()["ticket_id"]
approve_reopen_response = client.post(
f"/api/v1/approvals/{reopen_ticket_id}/approve",
headers=approval_headers,
json={"comment": "risk reopen approved"},
)
assert approve_reopen_response.status_code == 200
reopen_response = client.post(
f"/api/v1/risks/events/{event_id}/reopen",
headers=headers,
json={"comment": "recheck", "approval_ticket_id": reopen_ticket_id},
)
assert reopen_response.status_code == 200
assert reopen_response.json()["risk_event"]["status"] == "open"
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
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()