将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。 当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包), M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
242 lines
11 KiB
Python
242 lines
11 KiB
Python
"""
|
||
test_integration_seams.py — 汇合联调接缝验证(无 Docker,不依赖 Redis/DB)
|
||
验证三条核心接缝:
|
||
1. AIE→BE 接缝:pipeline_steps / pipeline_io 数据流契约
|
||
2. BE→FE 接缝:SSE 事件字段与 generationStore 期望字段对齐
|
||
3. FE→BE 接缝:API 请求 DTO 与 BE 路由入参对齐
|
||
|
||
原则:纯 Python/结构断言,不联网,不起服务。
|
||
联调真实运行需 docker compose up + 真实 API Key。
|
||
"""
|
||
import sys, os
|
||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||
|
||
import pytest
|
||
import json
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# Seam 1 — AIE→BE: pipeline_steps 数据流
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
class TestAiePipelineStepsContract:
|
||
"""pipeline_steps.build_product_dict 产出符合 AI 引擎 dict 契约"""
|
||
|
||
def _make_mock_product(self):
|
||
"""模拟 ORM Product 对象(duck typing)"""
|
||
class P:
|
||
name = "倍分子素颜霜"
|
||
category = "美妆护肤"
|
||
selling_points = '["轻薄不厚重","水润自然"]'
|
||
style_tone = "素人分享风"
|
||
text_angles = '["痛点切入","场景型"]'
|
||
custom_prompt = ""
|
||
image_path = None # 前端图片上传完成前为空
|
||
return P()
|
||
|
||
def test_build_product_dict_keys(self):
|
||
from app.workers.pipeline_steps import build_product_dict
|
||
d = build_product_dict(self._make_mock_product())
|
||
for key in ("name", "category", "selling_points", "style_tone",
|
||
"text_angles", "custom_prompt", "image_path"):
|
||
assert key in d, f"build_product_dict 缺字段: {key}"
|
||
|
||
def test_build_product_dict_lists_parsed(self):
|
||
from app.workers.pipeline_steps import build_product_dict
|
||
d = build_product_dict(self._make_mock_product())
|
||
assert isinstance(d["selling_points"], list)
|
||
assert isinstance(d["text_angles"], list)
|
||
|
||
def test_build_product_dict_no_key_field(self):
|
||
"""绝不含任何 key/token 相关字段(基石B)"""
|
||
from app.workers.pipeline_steps import build_product_dict
|
||
d = build_product_dict(self._make_mock_product())
|
||
for forbidden in ("api_key", "key", "token", "secret", "encrypted"):
|
||
assert forbidden not in d, f"product_dict 不应含 {forbidden}"
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# Seam 2 — BE→FE: SSE 事件字段对齐
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
class TestSseEventFieldAlignment:
|
||
"""BE 推的 SSE 事件字段与 FE useSse handleEvent 期望的字段一一对应"""
|
||
|
||
# FE useSse.ts 中 handleEvent 使用的字段(从 FE 代码提取)
|
||
EXPECTED_FIELDS = {
|
||
"task_started": {"total_text", "total_image"},
|
||
"text_progress": {"done", "total"},
|
||
"text_candidate": {"candidate_id", "angle_label", "content", "score"},
|
||
"image_progress": {"done", "total"},
|
||
"image_candidate": {"candidate_id", "strategy", "url", "role"},
|
||
"batch_failed": {"batch", "reason", "retryable"},
|
||
"flywheel_injected": {"recent_preference", "reject_reasons"},
|
||
}
|
||
|
||
# BE pipeline_io 实际推的事件字段(从代码提取)
|
||
ACTUAL_FIELDS = {
|
||
"task_started": {
|
||
"task_id": 1, "total_text": 5, "total_image": 3,
|
||
},
|
||
"text_progress": {
|
||
"done": 1, "total": 5,
|
||
},
|
||
"text_candidate": {
|
||
"candidate_id": 1, "angle_label": "场景型",
|
||
"content": "测试正文", "score": 88,
|
||
},
|
||
"image_progress": {
|
||
"done": 1, "total": 3,
|
||
},
|
||
"image_candidate": {
|
||
"candidate_id": 1, "strategy": "A", "url": "/uploads/ws/1/1/01_hook.jpg",
|
||
"role": "hook",
|
||
},
|
||
"batch_failed": {
|
||
"batch": "hook", "reason": "502", "retryable": True,
|
||
},
|
||
"flywheel_injected": {
|
||
"recent_preference": "最近偏好角度:场景型",
|
||
"reject_reasons": [],
|
||
},
|
||
}
|
||
|
||
def test_all_fe_required_fields_present(self):
|
||
"""FE 期望的每个字段都在 BE 实际推的 data 中"""
|
||
for event_type, required_fields in self.EXPECTED_FIELDS.items():
|
||
actual = self.ACTUAL_FIELDS.get(event_type, {})
|
||
for field in required_fields:
|
||
assert field in actual, (
|
||
f"SSE 事件 [{event_type}] 缺字段 [{field}],FE 会拿到 undefined"
|
||
)
|
||
|
||
def test_no_extra_pii_in_sse_events(self):
|
||
"""SSE 事件不含 key/token/password 等敏感字段"""
|
||
for event_type, data in self.ACTUAL_FIELDS.items():
|
||
for field in data:
|
||
assert "key" not in field.lower() and "token" not in field.lower(), (
|
||
f"SSE 事件 [{event_type}] 含敏感字段 [{field}]"
|
||
)
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# Seam 3 — FE→BE: CreateTaskRequest DTO 对齐
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
class TestCreateTaskRequestContract:
|
||
"""FE tasks/new/page.tsx 发的请求体与 BE CreateTaskRequest 契约对齐(独立 DTO 验证,不依赖 motor)"""
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _stub_motor(self, monkeypatch):
|
||
"""database.py 顶层 import motor 在测试环境可能缺失,mock 掉避免 ModuleNotFoundError"""
|
||
import sys
|
||
import types
|
||
if "motor" not in sys.modules:
|
||
motor_mod = types.ModuleType("motor")
|
||
motor_async = types.ModuleType("motor.motor_asyncio")
|
||
motor_async.AsyncIOMotorClient = object
|
||
sys.modules["motor"] = motor_mod
|
||
sys.modules["motor.motor_asyncio"] = motor_async
|
||
|
||
def _get_dto(self):
|
||
from app.api.v1.tasks import CreateTaskRequest
|
||
return CreateTaskRequest
|
||
|
||
def test_create_task_required_fields(self):
|
||
Req = self._get_dto()
|
||
req = Req(product_id=1, theme="今日主题", text_count=8, image_count=5, track="ai")
|
||
assert req.product_id == 1
|
||
assert req.theme == "今日主题"
|
||
assert req.track == "ai"
|
||
|
||
def test_count_validation(self):
|
||
import pydantic
|
||
Req = self._get_dto()
|
||
with pytest.raises(pydantic.ValidationError):
|
||
Req(product_id=1, text_count=0, track="ai") # 0 < 1
|
||
with pytest.raises(pydantic.ValidationError):
|
||
Req(product_id=1, text_count=21, track="ai") # 21 > 20
|
||
|
||
def test_track_validation(self):
|
||
import pydantic
|
||
Req = self._get_dto()
|
||
with pytest.raises(pydantic.ValidationError):
|
||
Req(product_id=1, track="auto") # 非法 track
|
||
|
||
def test_import_track_no_key_check(self):
|
||
"""轨B import 字段校验通过(业务层 key 检查在 service,不在 DTO)"""
|
||
Req = self._get_dto()
|
||
req = Req(product_id=1, track="import")
|
||
assert req.track == "import"
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# Seam 4 — SSE ticket 流程
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
class TestSseTicket:
|
||
"""ticket 签发/消费/防重放逻辑(用 mock redis)"""
|
||
|
||
def _make_mock_redis(self):
|
||
store: dict = {}
|
||
|
||
class MockRedis:
|
||
def setex(self, key, ttl, value):
|
||
store[key] = value
|
||
|
||
def getdel(self, key):
|
||
return store.pop(key, None)
|
||
|
||
return MockRedis(), store
|
||
|
||
def test_issue_and_consume(self):
|
||
from app.core.sse_ticket import issue_ticket, consume_ticket
|
||
r, _ = self._make_mock_redis()
|
||
ticket = issue_ticket(r, task_id=42, workspace_id=7)
|
||
result = consume_ticket(r, ticket)
|
||
assert result == (42, 7)
|
||
|
||
def test_consume_once_only(self):
|
||
from app.core.sse_ticket import issue_ticket, consume_ticket
|
||
r, _ = self._make_mock_redis()
|
||
ticket = issue_ticket(r, task_id=1, workspace_id=1)
|
||
consume_ticket(r, ticket) # 第一次消费
|
||
result = consume_ticket(r, ticket) # 第二次 → None
|
||
assert result is None
|
||
|
||
def test_invalid_ticket_returns_none(self):
|
||
from app.core.sse_ticket import consume_ticket
|
||
r, _ = self._make_mock_redis()
|
||
assert consume_ticket(r, "fake-ticket") is None
|
||
|
||
def test_empty_ticket_returns_none(self):
|
||
from app.core.sse_ticket import consume_ticket
|
||
r, _ = self._make_mock_redis()
|
||
assert consume_ticket(r, "") is None
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# Seam 5 — SSE sse_utils 格式化
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
class TestSseFormat:
|
||
"""format_sse 输出符合 text/event-stream 规范,FE EventSource 能解析"""
|
||
|
||
def test_format_contains_event_and_data(self):
|
||
from app.utils.sse_utils import format_sse
|
||
out = format_sse("text_candidate", {"candidate_id": 1, "score": 92}, seq=3)
|
||
assert "event: text_candidate" in out
|
||
assert "data:" in out
|
||
assert "id: 3" in out
|
||
|
||
def test_format_ends_with_double_newline(self):
|
||
from app.utils.sse_utils import format_sse
|
||
out = format_sse("task_done", {"status": "pending_selection"})
|
||
assert out.endswith("\n\n")
|
||
|
||
def test_data_is_valid_json(self):
|
||
from app.utils.sse_utils import format_sse
|
||
out = format_sse("heartbeat", {"ts": 1234567.0})
|
||
data_line = [l for l in out.split("\n") if l.startswith("data:")][0]
|
||
json.loads(data_line[len("data:"):].strip()) # 不应抛异常
|