v0.1.0: CRM/ERP 系统内测版本 - 安全加固完成
- Docker bridge 网络隔离(8000 端口封死) - Gunicorn 4 Worker 多进程 - Alembic 数据库迁移基线 - 日志轮转 20m×3 - JWT 密钥 + DB 密码 + CORS 收紧 - 3-2-1 备份链路(NAS + R740-B 冷备) - 连接池 pool_pre_ping + pool_recycle=3600
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
SHBL-CRM Integration Tests
|
||||
Module 3: Business Logic + AI Workflow
|
||||
|
||||
Test 1: POST /api/v1/logs - Event-driven AI background task
|
||||
Test 2: GET /api/v1/reports/monthly - SQL pre-aggregation + AI report
|
||||
|
||||
Prerequisites:
|
||||
- Backend running: uvicorn app.main:app (port 8000)
|
||||
- PostgreSQL + Alembic migration applied
|
||||
- Ollama node reachable (for AI tests)
|
||||
|
||||
Run: pytest tests/test_api_integration.py -v -s
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
# ---- Logging setup for test output ----
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s | %(name)-12s | %(levelname)-5s | %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("test_integration")
|
||||
|
||||
# ---- Config ----
|
||||
BASE_URL = "http://127.0.0.1:8000/api/v1"
|
||||
|
||||
# Test fixtures: pre-seeded client & auth
|
||||
# Must insert a test client into DB before running,
|
||||
# or use the seed script. This UUID will be used for all tests.
|
||||
TEST_CLIENT_ID: str | None = None # Populated by fixture
|
||||
TEST_TOKEN: str | None = None # Populated by fixture
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Fixtures
|
||||
# ============================================================
|
||||
|
||||
@pytest_asyncio.fixture(scope="module")
|
||||
async def seed_test_data():
|
||||
"""
|
||||
Seed a test client and admin user into the database,
|
||||
then authenticate to get a JWT token.
|
||||
"""
|
||||
global TEST_CLIENT_ID, TEST_TOKEN
|
||||
|
||||
# Create admin user + test client via direct DB insert
|
||||
import psycopg2
|
||||
import bcrypt
|
||||
|
||||
admin_hash = bcrypt.hashpw(b"admin123", bcrypt.gensalt()).decode("utf-8")
|
||||
test_client_uuid = str(uuid.uuid4())
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host="192.168.1.85",
|
||||
port=5432,
|
||||
user="admin",
|
||||
password="admin_password_2026",
|
||||
dbname="lubrication_crm",
|
||||
)
|
||||
cur = conn.cursor()
|
||||
|
||||
# Seed admin user (idempotent)
|
||||
cur.execute("""
|
||||
INSERT INTO users (id, username, password_hash, role, is_active)
|
||||
VALUES (%s, 'test_admin', %s, 'admin', true)
|
||||
ON CONFLICT (username) DO UPDATE SET password_hash = EXCLUDED.password_hash
|
||||
""", (str(uuid.uuid4()), admin_hash))
|
||||
|
||||
# Seed test client
|
||||
cur.execute("""
|
||||
INSERT INTO clients (id, name, contact_person, phone)
|
||||
VALUES (%s, 'Test_Integration_Client', 'Zhang San', '13800138000')
|
||||
""", (test_client_uuid,))
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
TEST_CLIENT_ID = test_client_uuid
|
||||
|
||||
# Authenticate to get JWT token
|
||||
async with httpx.AsyncClient(base_url=BASE_URL, timeout=10.0) as client:
|
||||
resp = await client.post("/auth/login", json={
|
||||
"username": "test_admin",
|
||||
"password": "admin123",
|
||||
})
|
||||
assert resp.status_code == 200, f"Login failed: {resp.text}"
|
||||
TEST_TOKEN = resp.json()["access_token"]
|
||||
logger.info("Auth token acquired for test_admin")
|
||||
|
||||
yield
|
||||
|
||||
# Cleanup: remove test data
|
||||
conn = psycopg2.connect(
|
||||
host="192.168.1.85", port=5432, user="admin",
|
||||
password="admin_password_2026", dbname="lubrication_crm",
|
||||
)
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM customer_tags WHERE customer_id = %s", (test_client_uuid,))
|
||||
cur.execute("DELETE FROM follow_up_todos WHERE customer_id = %s", (test_client_uuid,))
|
||||
cur.execute("DELETE FROM customer_logs WHERE customer_id = %s", (test_client_uuid,))
|
||||
cur.execute("DELETE FROM sales_opportunities WHERE customer_id = %s", (test_client_uuid,))
|
||||
cur.execute("DELETE FROM clients WHERE id = %s", (test_client_uuid,))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
logger.info("Test data cleaned up")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Test Case 1: POST /api/v1/logs + AI Background Task
|
||||
# ============================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_log_and_ai_background_task(seed_test_data):
|
||||
"""
|
||||
Test the event-driven AI processing pipeline:
|
||||
1. POST log → 200 OK (immediate)
|
||||
2. Wait for BackgroundTasks to call Ollama
|
||||
3. Verify tags & todos were written to DB
|
||||
"""
|
||||
assert TEST_TOKEN, "Auth token missing"
|
||||
assert TEST_CLIENT_ID, "Test client ID missing"
|
||||
|
||||
headers = {"Authorization": f"Bearer {TEST_TOKEN}"}
|
||||
log_content = "今天拜访了张总,客户对价格敏感,要求下周二前给折扣方案"
|
||||
|
||||
# ---- Step 1: Submit log ----
|
||||
async with httpx.AsyncClient(base_url=BASE_URL, timeout=15.0) as client:
|
||||
resp = await client.post(
|
||||
"/logs",
|
||||
json={
|
||||
"customer_id": TEST_CLIENT_ID,
|
||||
"content": log_content,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
# ---- Assert 1: Immediate response ----
|
||||
assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}"
|
||||
data = resp.json()
|
||||
assert "id" in data, "Response missing 'id' field"
|
||||
log_id = data["id"]
|
||||
logger.info("Log submitted successfully: id=%s", log_id)
|
||||
|
||||
# ---- Step 2: Wait for AI background task ----
|
||||
# BackgroundTasks calls Ollama (qwen3:14b), may take 5-30s
|
||||
logger.info("Waiting 10s for AI background processing...")
|
||||
await asyncio.sleep(10)
|
||||
|
||||
# ---- Assert 2: Verify DB side effects ----
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(
|
||||
host="192.168.1.85", port=5432, user="admin",
|
||||
password="admin_password_2026", dbname="lubrication_crm",
|
||||
)
|
||||
cur = conn.cursor()
|
||||
|
||||
# Check customer_tags
|
||||
cur.execute(
|
||||
"SELECT tag_name FROM customer_tags WHERE customer_id = %s",
|
||||
(TEST_CLIENT_ID,),
|
||||
)
|
||||
tags = [r[0] for r in cur.fetchall()]
|
||||
logger.info("Generated tags: %s", tags)
|
||||
|
||||
# Check follow_up_todos
|
||||
cur.execute(
|
||||
"SELECT task_desc, status FROM follow_up_todos WHERE customer_id = %s",
|
||||
(TEST_CLIENT_ID,),
|
||||
)
|
||||
todos = cur.fetchall()
|
||||
logger.info("Generated todos: %s", todos)
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
# AI may or may not succeed (Ollama connectivity),
|
||||
# but if it did, we should see results.
|
||||
# Use soft assertion: log warning if empty, don't fail hard
|
||||
# (Ollama node might be unreachable in test env)
|
||||
if tags:
|
||||
assert len(tags) <= 3, f"Expected at most 3 tags, got {len(tags)}"
|
||||
logger.info("PASS: AI generated %d tag(s)", len(tags))
|
||||
else:
|
||||
logger.warning(
|
||||
"WARNING: No tags generated. "
|
||||
"Check Ollama connectivity and backend logs for errors."
|
||||
)
|
||||
|
||||
if todos:
|
||||
assert todos[0][1] == "pending", "Todo status should be 'pending'"
|
||||
logger.info("PASS: AI generated todo: %s", todos[0][0][:80])
|
||||
else:
|
||||
logger.warning(
|
||||
"WARNING: No todos generated. "
|
||||
"Check Ollama connectivity and backend logs for errors."
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Test Case 2: GET /api/v1/reports/monthly
|
||||
# ============================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_monthly_sales_report_generation(seed_test_data):
|
||||
"""
|
||||
Test SQL pre-aggregation + AI report generation:
|
||||
1. GET /reports/monthly → 200
|
||||
2. Response contains metrics list + report string
|
||||
"""
|
||||
assert TEST_TOKEN, "Auth token missing"
|
||||
|
||||
headers = {"Authorization": f"Bearer {TEST_TOKEN}"}
|
||||
|
||||
# ---- Step 1: Request monthly report ----
|
||||
# This is a synchronous wait for AI generation, set generous timeout
|
||||
async with httpx.AsyncClient(base_url=BASE_URL, timeout=90.0) as client:
|
||||
logger.info("Requesting monthly report (may take 30-60s for AI generation)...")
|
||||
resp = await client.get("/reports/monthly", headers=headers)
|
||||
|
||||
# ---- Assert 1: Status code ----
|
||||
assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}"
|
||||
|
||||
data = resp.json()
|
||||
logger.info("Report response received (%d bytes)", len(resp.text))
|
||||
|
||||
# ---- Assert 2: Response structure ----
|
||||
assert "metrics" in data, "Response missing 'metrics' field"
|
||||
assert "report" in data, "Response missing 'report' field"
|
||||
assert isinstance(data["metrics"], list), "'metrics' should be a list"
|
||||
assert isinstance(data["report"], str), "'report' should be a string"
|
||||
assert len(data["report"]) > 0, "'report' should be non-empty"
|
||||
|
||||
logger.info("Metrics: %s", data["metrics"])
|
||||
logger.info("Report preview: %s...", data["report"][:200])
|
||||
logger.info("PASS: Monthly report generated (%d chars)", len(data["report"]))
|
||||
Reference in New Issue
Block a user