test: 补齐上线关键链路自动验证

This commit is contained in:
2026-07-10 11:54:49 +08:00
parent fb39c9c543
commit 7f835ec699
5 changed files with 157 additions and 25 deletions

View File

@@ -10,15 +10,15 @@ from app.models import Base
settings = get_settings()
engine = create_engine(
settings.database_url,
pool_pre_ping=True,
pool_size=settings.db_pool_size,
max_overflow=settings.db_max_overflow,
pool_timeout=settings.db_pool_timeout,
pool_recycle=settings.db_pool_recycle,
future=True,
)
engine_options = {"pool_pre_ping": True, "future": True}
if not settings.database_url.startswith("sqlite"):
engine_options.update(
pool_size=settings.db_pool_size,
max_overflow=settings.db_max_overflow,
pool_timeout=settings.db_pool_timeout,
pool_recycle=settings.db_pool_recycle,
)
engine = create_engine(settings.database_url, **engine_options)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)

View File

@@ -0,0 +1,2 @@
-r requirements.txt
pytest==9.1.1

View File

@@ -1,16 +1,16 @@
fastapi>=0.116.0
uvicorn[standard]>=0.35.0
SQLAlchemy>=2.0.40
alembic>=1.16.0
pydantic>=2.11.0
pydantic-settings>=2.10.0
python-dotenv>=1.1.0
PyJWT>=2.10.0
PyMySQL>=1.1.1
cryptography>=45.0.0
httpx>=0.28.0
redis>=5.0.0
openpyxl>=3.1.5
python-multipart>=0.0.20
alibabacloud_dysmsapi20170525>=4.1.2
Pillow>=11.0.0
fastapi==0.139.0
uvicorn[standard]==0.50.1
SQLAlchemy==2.0.51
alembic==1.18.5
pydantic==2.13.4
pydantic-settings==2.14.2
python-dotenv==1.2.2
PyJWT==2.13.0
PyMySQL==1.2.0
cryptography==46.0.5
httpx==0.28.1
redis==7.4.1
openpyxl==3.1.5
python-multipart==0.0.32
alibabacloud-dysmsapi20170525==4.6.0
Pillow==12.3.0

View File

@@ -0,0 +1,115 @@
from __future__ import annotations
from datetime import date
import asyncio
import json
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.api import chat
from app.models import Base
from app.models.ai_config import SystemConfig
from app.models.user import User
from app.services.chat_queue_runtime import ChatQueueLease
from app.services.chat_queue_service import ChatQueueConfig, QueueRequest
from app.services.chat_service import ChatService
from app.services.secret_service import ENCRYPTED_PREFIX, MASKED_SECRET, SecretService
from app.services.security_state_service import SecurityStateService
@pytest.fixture(autouse=True)
def reset_security_state(monkeypatch):
monkeypatch.setattr("app.services.security_state_service.get_sync_redis_client", lambda: None)
SecurityStateService._counters.clear()
def test_secret_is_encrypted_and_masked():
encrypted = SecretService.encrypt("production-secret")
assert encrypted.startswith(ENCRYPTED_PREFIX)
assert "production-secret" not in encrypted
assert SecretService.decrypt(encrypted) == "production-secret"
assert SecretService.masked(encrypted) == MASKED_SECRET
def test_rate_limit_blocks_after_limit():
SecurityStateService.enforce_limit("test:rate", limit=2, window_seconds=60, message="too many")
SecurityStateService.enforce_limit("test:rate", limit=2, window_seconds=60, message="too many")
with pytest.raises(HTTPException) as exc:
SecurityStateService.enforce_limit("test:rate", limit=2, window_seconds=60, message="too many")
assert exc.value.status_code == 429
def test_revoked_token_is_shared_security_state():
SecurityStateService.revoke_token("token-id", None)
assert SecurityStateService.is_token_revoked("token-id") is True
def test_daily_quota_resets_for_new_day():
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
with Session(engine) as db:
user = User(
id=1,
phone="13800000001",
name="测试用户",
daily_chat_limit=10,
daily_chat_used=10,
daily_chat_reset_date=date(2020, 1, 1),
)
db.add(user)
db.commit()
prepared = ChatService.prepare_daily_quota(db, user)
assert prepared.daily_chat_used == 0
assert prepared.daily_chat_reset_date > date(2020, 1, 1)
def test_queued_chat_reports_position_and_completes(monkeypatch):
config = ChatQueueConfig(1, 10, 10, 60)
queued = ChatQueueLease("memory", QueueRequest("queued", token=object(), position=2, active_count=1, waiting_count=2))
acquired = ChatQueueLease("memory", QueueRequest("acquired", token=queued.request.token, active_count=1))
released = False
async def request_slot(_config):
return queued
async def wait_slot(_lease, _config):
return acquired
async def release_slot(_lease):
nonlocal released
released = True
async def stream_answer(*_args, **_kwargs):
yield "回答"
monkeypatch.setattr(chat, "load_chat_queue_config", lambda _db: config)
monkeypatch.setattr(chat, "request_chat_slot", request_slot)
monkeypatch.setattr(chat, "wait_for_chat_slot", wait_slot)
monkeypatch.setattr(chat, "release_chat_slot", release_slot)
monkeypatch.setattr(chat.ChatStreamService, "stream_answer_async", stream_answer)
payload = type("Payload", (), {"sessionId": 1, "message": "问题"})()
async def collect_events():
return [item async for item in chat._chat_stream(payload, object(), object())]
events = asyncio.run(collect_events())
decoded = [json.loads(item.removeprefix("data: ")) for item in events if item != "data: [DONE]\n\n"]
assert decoded[0]["type"] == "queued"
assert any(item["type"] == "content" and item["content"] == "回答" for item in decoded)
assert released is True
def test_sensitive_system_config_never_returns_plaintext():
from app.api.admin_settings import _config_dict
config = SystemConfig(config_key="feishu_app_secret", config_value="plain-secret")
result = _config_dict(config)
assert result["configValue"] == MASKED_SECRET

View File

@@ -15,6 +15,9 @@ print(f"OpenAPI paths: {len(paths)}")
assert len(paths) >= 32
PY
echo "== 后端关键测试 =="
./.venv/bin/python -m pytest -q
echo "== Alembic MySQL 静态 SQL 检查 =="
./.venv/bin/alembic upgrade head --sql >/tmp/ai_kb_v2_migration.sql
wc -l /tmp/ai_kb_v2_migration.sql
@@ -31,4 +34,16 @@ echo "== Docker Compose 配置检查 =="
cd "$ROOT_DIR"
docker compose -f docker-compose.dev.yml config --quiet
echo "== 生产 Docker Compose 配置检查 =="
MYSQL_ROOT_PASSWORD=test-root \
MYSQL_PASSWORD=test-db \
REDIS_PASSWORD=test-redis \
DATABASE_URL='mysql+pymysql://ai_kb:test-db@mysql:3306/ai_knowledge_base_v2?charset=utf8mb4' \
REDIS_URL='redis://:test-redis@redis:6379/0' \
JWT_SECRET_KEY='test-jwt-secret-at-least-32-characters' \
CONFIG_ENCRYPTION_KEY='MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=' \
BOOTSTRAP_ADMIN_USERNAME=admin \
BOOTSTRAP_ADMIN_PASSWORD='Strong-Test-Password-2026' \
docker compose -f docker-compose.prod.yml config --quiet
echo "阶段六自动验证完成"