baseline: Clover 独立仓库首次基线提交
将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。 当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包), M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
56
backend/tests/conftest.py
Normal file
56
backend/tests/conftest.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
conftest.py — 测试环境 stub
|
||||
在任何模块 import 之前注入环境变量,让 Settings 校验通过。
|
||||
只用于本地单元/集成测试,不影响生产。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
# ── 1. 注入最小可用配置(全假值,只让 Settings 构建通过)────────
|
||||
_TEST_ENV = {
|
||||
"FERNET_KEY": "test-fernet-key-at-least-32-chars-long!!",
|
||||
"JWT_SECRET": "test-jwt-secret-at-least-32-chars-long!!",
|
||||
"DATABASE_URL": "mysql+pymysql://test:test@localhost/test",
|
||||
"MONGO_URI": "mongodb://localhost:27017/test",
|
||||
"REDIS_URL": "redis://localhost:6379/0",
|
||||
"APP_ENV": "test",
|
||||
}
|
||||
for k, v in _TEST_ENV.items():
|
||||
os.environ.setdefault(k, v)
|
||||
|
||||
# ── 2. 清掉 lru_cache,让 Settings 用刚注入的 env vars 重建 ─────
|
||||
from app.core import config as _cfg
|
||||
_cfg.get_settings.cache_clear()
|
||||
|
||||
# ── 3. Stub motor(MongoDB client,本地没装)──────────────────────
|
||||
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
|
||||
|
||||
# ── 4. Stub database engine(MySQL URL 但不连接)──────────────────
|
||||
# database.py 顶层调 create_engine,pytest 环境没有 MySQL。
|
||||
# 只 stub engine 和 SessionLocal,不影响任何逻辑测试。
|
||||
import unittest.mock as _mock
|
||||
import sqlalchemy
|
||||
|
||||
_fake_engine = _mock.MagicMock()
|
||||
_fake_session_class = _mock.MagicMock()
|
||||
_fake_session_class.return_value.__enter__ = _mock.MagicMock(return_value=_mock.MagicMock())
|
||||
_fake_session_class.return_value.__exit__ = _mock.MagicMock(return_value=False)
|
||||
|
||||
# 只在 database 模块还未 import 时 patch;若已 import 则直接替换属性
|
||||
if "app.core.database" not in sys.modules:
|
||||
with _mock.patch("sqlalchemy.create_engine", return_value=_fake_engine):
|
||||
with _mock.patch("sqlalchemy.orm.sessionmaker", return_value=_fake_session_class):
|
||||
import app.core.database as _db_mod
|
||||
else:
|
||||
_db_mod = sys.modules["app.core.database"]
|
||||
|
||||
# 确保 engine/SessionLocal 是 mock,防止实际连接
|
||||
_db_mod.engine = _fake_engine
|
||||
_db_mod.SessionLocal = _fake_session_class
|
||||
|
||||
253
backend/tests/test_ai_engine.py
Normal file
253
backend/tests/test_ai_engine.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
AI 引擎单元测试(核心逻辑覆盖)
|
||||
对照 JS 版逻辑验证 Python 重写正确性
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
import pytest
|
||||
from app.services.ai_engine.text_scoring import score_copy, is_similar_copy, dedupe_copies
|
||||
from app.services.ai_engine.banned_word_checker import check_and_fix, BannedWordEntry
|
||||
from app.services.ai_engine.storyboard import get_narrative_roles, plan_image_set, clamp_count
|
||||
from app.services.ai_engine.preference_aggregator import aggregate_preference_context, collect_preference_event
|
||||
from app.services.ai_engine.prompt_composer import (
|
||||
compose_variants, compose_preference_context, compose_image_prompt, parse_model_output
|
||||
)
|
||||
|
||||
# ── 测试用 product ─────────────────────────────────────────
|
||||
PRODUCT = {
|
||||
"name": "倍分子素颜霜",
|
||||
"category": "美妆护肤",
|
||||
"selling_points": ["轻薄不厚重", "水润自然", "不卡粉"],
|
||||
"keywords": ["素颜霜", "日常通勤"],
|
||||
"style_tone": "素人分享风",
|
||||
"text_angles": ["痛点切入", "场景型", "避坑型"],
|
||||
"custom_prompt": "",
|
||||
"target_audience": "上班族",
|
||||
}
|
||||
|
||||
GOOD_COPY = {
|
||||
"title": "早八素颜也能有点气色!✨",
|
||||
"content": "姐妹们,最近实测下来,轻薄不厚重是我最在意的。✅ 通勤路上随手一抹,水润自然有气色。不卡粉这点太加分了。整体就是那种上班族能马上代入的日常好物。",
|
||||
"tags": ["#素颜霜", "#日常通勤", "#好物分享", "#真实测评"],
|
||||
"angle": "场景型",
|
||||
"buyingPoint": "轻薄不厚重,适合上班族",
|
||||
"coverTitle": "早八素颜也能有点气色",
|
||||
"imageBrief": "封面自然光上脸局部,内页质地推开+软性转化。",
|
||||
"source": "ai",
|
||||
}
|
||||
|
||||
|
||||
# ── 五维打分 ───────────────────────────────────────────────
|
||||
class TestScoreCopy:
|
||||
def test_good_copy_passes(self):
|
||||
result = score_copy(GOOD_COPY, PRODUCT)
|
||||
assert result["passed"] is True
|
||||
assert result["score"] >= 90
|
||||
|
||||
def test_banned_word_kills_compliance(self):
|
||||
bad = {**GOOD_COPY, "title": "美白素颜霜强推!", "content": GOOD_COPY["content"] + "美白效果显著"}
|
||||
result = score_copy(bad, PRODUCT)
|
||||
# 含美白 → 合规0分 + 总分不过
|
||||
assert result["passed"] is False
|
||||
compliance = next(d for d in result["score_detail"] if d["item"] == "合规性")
|
||||
assert compliance["score"] == 0
|
||||
|
||||
def test_score_detail_has_five_dims(self):
|
||||
result = score_copy(GOOD_COPY, PRODUCT)
|
||||
assert len(result["score_detail"]) == 5
|
||||
items = {d["item"] for d in result["score_detail"]}
|
||||
assert "标题吸引力" in items
|
||||
assert "情绪共鸣" in items
|
||||
assert "买点表达" in items
|
||||
assert "关键词覆盖" in items
|
||||
assert "合规性" in items
|
||||
|
||||
|
||||
# ── 去重 ───────────────────────────────────────────────────
|
||||
class TestDedup:
|
||||
def test_identical_title_deduped(self):
|
||||
a = {**GOOD_COPY}
|
||||
b = {**GOOD_COPY, "content": "略有不同的内容,但标题一样"}
|
||||
result = dedupe_copies([a, b])
|
||||
assert len(result) == 1
|
||||
|
||||
def test_different_angle_both_kept(self):
|
||||
a = {**GOOD_COPY, "angle": "痛点切入"}
|
||||
b = {**GOOD_COPY, "title": "通勤懒人必囤!换季不再干脸", "angle": "避坑型"}
|
||||
result = dedupe_copies([a, b])
|
||||
assert len(result) == 2
|
||||
|
||||
def test_similar_body_deduped(self):
|
||||
a = {**GOOD_COPY}
|
||||
b = {**GOOD_COPY, "title": "不一样的标题", "content": GOOD_COPY["content"][:150] + "稍有变动"}
|
||||
assert is_similar_copy(a, b) or len(dedupe_copies([a, b])) <= 2
|
||||
|
||||
|
||||
# ── 违禁词三级 ──────────────────────────────────────────────
|
||||
class TestBannedWordChecker:
|
||||
def test_hard_block(self):
|
||||
# "美白"已改为 auto_fix(提亮肤色感),hard_block 用速效/医用等词
|
||||
result = check_and_fix("速效医用护肤品")
|
||||
assert result.status == "hard_block"
|
||||
|
||||
def test_meibaibanned_now_auto_fix(self):
|
||||
# Q5对齐:美白→auto_fix 提亮肤色感(不再 hard_block)
|
||||
result = check_and_fix("这款美白效果很好")
|
||||
assert result.status == "auto_fixed"
|
||||
assert result.fixed_text is not None
|
||||
assert "美白" not in result.fixed_text
|
||||
|
||||
def test_auto_fix(self):
|
||||
result = check_and_fix("这款神器真的好用")
|
||||
assert result.status == "auto_fixed"
|
||||
assert result.fixed_text is not None
|
||||
assert "神器" not in result.fixed_text
|
||||
|
||||
def test_soft_warn(self):
|
||||
result = check_and_fix("这绝对是最好的护肤品")
|
||||
assert result.status == "soft_warn"
|
||||
|
||||
def test_clean_text_passes(self):
|
||||
result = check_and_fix("这款素颜霜轻薄水润,通勤必备。")
|
||||
assert result.status == "pass"
|
||||
|
||||
def test_custom_entries_override(self):
|
||||
entries = [BannedWordEntry("必备", "hard_block")]
|
||||
result = check_and_fix("通勤必备好物", entries)
|
||||
assert result.status == "hard_block"
|
||||
|
||||
|
||||
# ── storyboard ─────────────────────────────────────────────
|
||||
class TestStoryboard:
|
||||
def test_clamp_count(self):
|
||||
assert clamp_count(0) == 1
|
||||
assert clamp_count(9) == 8
|
||||
assert clamp_count(3) == 3
|
||||
|
||||
def test_narrative_roles_3(self):
|
||||
roles = get_narrative_roles(3)
|
||||
assert len(roles) == 3
|
||||
assert roles[0]["role"] == "hook"
|
||||
assert roles[-1]["role"] == "closer"
|
||||
|
||||
def test_narrative_roles_6(self):
|
||||
roles = get_narrative_roles(6)
|
||||
assert len(roles) == 6
|
||||
# Q6对齐北哥套路:①hook ②product_closeup(单品特写) ③ingredient ④texture ⑤applied_proof ⑥closer
|
||||
assert roles[0]["role"] == "hook"
|
||||
assert roles[1]["role"] == "product_closeup"
|
||||
assert roles[2]["role"] == "ingredient"
|
||||
assert roles[4]["role"] == "applied_proof"
|
||||
assert roles[5]["role"] == "closer"
|
||||
|
||||
def test_narrative_roles_8(self):
|
||||
roles = get_narrative_roles(8)
|
||||
assert len(roles) == 8
|
||||
|
||||
def test_plan_image_set_structure(self):
|
||||
plan = plan_image_set(GOOD_COPY, PRODUCT, image_count=3)
|
||||
assert "storyboard" in plan
|
||||
assert "base_prompt" in plan
|
||||
assert len(plan["storyboard"]) == 3
|
||||
# 产品图锚点说明必须在 base_prompt 中
|
||||
assert "不可修改" in plan["base_prompt"]
|
||||
|
||||
|
||||
# ── 飞轮聚合 ───────────────────────────────────────────────
|
||||
class TestPreferenceAggregator:
|
||||
def test_cold_start_when_few_events(self):
|
||||
result = aggregate_preference_context([], PRODUCT, workspace_id=1, product_id=1)
|
||||
assert result["injected_count"] == 0
|
||||
assert "冷启动" in result["recent_preference"]
|
||||
|
||||
def test_aggregate_top_angles(self):
|
||||
events = [
|
||||
{"signal_type": "text_select", "workspace_id": 1, "product_id": 1, "angle_label": "痛点切入", "signal_weight": 3, "reason": ""},
|
||||
{"signal_type": "text_select", "workspace_id": 1, "product_id": 1, "angle_label": "痛点切入", "signal_weight": 3, "reason": ""},
|
||||
{"signal_type": "approve", "workspace_id": 1, "product_id": 1, "angle_label": "场景型", "signal_weight": 5, "reason": ""},
|
||||
{"signal_type": "text_select", "workspace_id": 1, "product_id": 1, "angle_label": "避坑型", "signal_weight": 3, "reason": ""},
|
||||
{"signal_type": "text_select", "workspace_id": 1, "product_id": 1, "angle_label": "痛点切入", "signal_weight": 3, "reason": ""},
|
||||
{"signal_type": "text_select", "workspace_id": 1, "product_id": 1, "angle_label": "避坑型", "signal_weight": 3, "reason": ""},
|
||||
]
|
||||
result = aggregate_preference_context(events, PRODUCT, workspace_id=1, product_id=1)
|
||||
assert result["injected_count"] == 6
|
||||
assert "痛点切入" in result["recent_preference"]
|
||||
assert "偏好角度" in result["prompt_fragment"]
|
||||
|
||||
def test_reject_reason_in_prompt(self):
|
||||
events = [
|
||||
{"signal_type": "reject_with_reason", "workspace_id": 1, "product_id": 1,
|
||||
"angle_label": "", "signal_weight": -3, "reason": "标题太硬广"},
|
||||
*[{"signal_type": "text_select", "workspace_id": 1, "product_id": 1,
|
||||
"angle_label": "痛点切入", "signal_weight": 3, "reason": ""} for _ in range(5)],
|
||||
]
|
||||
result = aggregate_preference_context(events, PRODUCT, workspace_id=1, product_id=1)
|
||||
assert "标题太硬广" in result["prompt_fragment"]
|
||||
|
||||
def test_product_isolation(self):
|
||||
"""不同 product_id 的事件不会混进来"""
|
||||
events = [
|
||||
{"signal_type": "text_select", "workspace_id": 1, "product_id": 2,
|
||||
"angle_label": "成分党", "signal_weight": 3, "reason": ""},
|
||||
*[{"signal_type": "text_select", "workspace_id": 1, "product_id": 1,
|
||||
"angle_label": "场景型", "signal_weight": 3, "reason": ""} for _ in range(5)],
|
||||
]
|
||||
result = aggregate_preference_context(events, PRODUCT, workspace_id=1, product_id=1)
|
||||
assert "成分党" not in result["prompt_fragment"]
|
||||
|
||||
def test_collect_event_structure(self):
|
||||
event = collect_preference_event("text_select", user_id=1, workspace_id=1, product_id=1, angle_label="痛点切入")
|
||||
assert event["signal_weight"] == 3
|
||||
assert event["data_ownership"] == "client_data"
|
||||
|
||||
|
||||
# ── prompt_composer ────────────────────────────────────────
|
||||
class TestPromptComposer:
|
||||
def test_compose_variants_returns_two_strings(self):
|
||||
sys_p, user_p = compose_variants(PRODUCT, count=5)
|
||||
assert isinstance(sys_p, str) and len(sys_p) > 0
|
||||
assert isinstance(user_p, str) and len(user_p) > 0
|
||||
|
||||
def test_compose_variants_includes_product_name(self):
|
||||
_, user_p = compose_variants(PRODUCT, count=3)
|
||||
assert "倍分子素颜霜" in user_p
|
||||
|
||||
def test_compose_variants_includes_count(self):
|
||||
_, user_p = compose_variants(PRODUCT, count=7)
|
||||
assert "7" in user_p
|
||||
|
||||
def test_compose_variants_flywheel_injected(self):
|
||||
fragment = "偏好角度参考:场景型、痛点切入"
|
||||
_, user_p = compose_variants(PRODUCT, count=3, flywheel_context=fragment)
|
||||
assert "场景型" in user_p
|
||||
|
||||
def test_compose_preference_context_delegates(self):
|
||||
# compose_preference_context 应委托给 aggregate_preference_context
|
||||
events = [
|
||||
{"signal_type": "text_select", "workspace_id": 1, "product_id": 1,
|
||||
"angle_label": "场景型", "signal_weight": 3, "reason": ""}
|
||||
for _ in range(6)
|
||||
]
|
||||
result = compose_preference_context(events, PRODUCT, workspace_id=1, product_id=1)
|
||||
assert "injected_count" in result
|
||||
assert result["injected_count"] == 6
|
||||
assert "prompt_fragment" in result
|
||||
|
||||
def test_parse_model_output_valid_json(self):
|
||||
raw = '[{"title":"标题1","content":"正文","tags":[],"angle":"场景型"}]'
|
||||
parsed = parse_model_output(raw)
|
||||
assert len(parsed) == 1 and parsed[0]["title"] == "标题1"
|
||||
|
||||
def test_parse_model_output_markdown_wrapped(self):
|
||||
raw = "```json\n[{\"title\":\"a\",\"content\":\"b\",\"tags\":[]}]\n```"
|
||||
parsed = parse_model_output(raw)
|
||||
assert len(parsed) == 1
|
||||
|
||||
def test_compose_image_prompt_includes_role_and_product(self):
|
||||
vs = {"style": "ins摆拍风", "color_palette": "米白+杏色", "base_prompt": "产品近景"}
|
||||
prompt = compose_image_prompt("hook", vs, PRODUCT)
|
||||
assert "hook" in prompt
|
||||
assert "倍分子素颜霜" in prompt
|
||||
assert "ins摆拍风" in prompt
|
||||
141
backend/tests/test_factory_and_exporter.py
Normal file
141
backend/tests/test_factory_and_exporter.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
gemini_factory + package_exporter 单元测试
|
||||
"""
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.ai_engine.gemini_factory import (
|
||||
build_ai_clients, _extract_image_bytes, _extract_gemini_image
|
||||
)
|
||||
from app.services.ai_engine.package_exporter import build_delivery_package
|
||||
|
||||
|
||||
# ── gemini_factory ─────────────────────────────────────────
|
||||
class TestGeminiFactory:
|
||||
def test_build_clients_returns_ai_clients(self, monkeypatch):
|
||||
monkeypatch.setenv("IMAGE_API_BASE", "https://api.example.com")
|
||||
clients = build_ai_clients(plain_key="test_key_1234")
|
||||
assert clients is not None
|
||||
assert clients._model == clients._model_text
|
||||
|
||||
def test_model_property_uses_env(self, monkeypatch):
|
||||
monkeypatch.setenv("IMAGE_API_BASE", "https://api.example.com")
|
||||
monkeypatch.setenv("MODEL_TEXT", "gpt-5.5")
|
||||
clients = build_ai_clients(plain_key="test_key_1234")
|
||||
assert clients._model_text == "gpt-5.5"
|
||||
|
||||
def test_extract_image_bytes_b64(self):
|
||||
import base64
|
||||
fake_png = b"\x89PNG fake"
|
||||
b64 = base64.b64encode(fake_png).decode()
|
||||
result = _extract_image_bytes({"data": [{"b64_json": b64}]})
|
||||
assert result == fake_png
|
||||
|
||||
def test_extract_image_bytes_empty_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
_extract_image_bytes({"data": []})
|
||||
|
||||
def test_extract_gemini_image_success(self):
|
||||
import base64
|
||||
fake_img = b"\xff\xd8\xff fake jpeg"
|
||||
b64 = base64.b64encode(fake_img).decode()
|
||||
resp = {"candidates": [{"content": {"parts": [{"inlineData": {"data": b64}}]}}]}
|
||||
result = _extract_gemini_image(resp)
|
||||
assert result == fake_img
|
||||
|
||||
def test_extract_gemini_image_no_image_raises(self):
|
||||
resp = {"candidates": [{"content": {"parts": [{"text": "no image"}]}}]}
|
||||
with pytest.raises(ValueError):
|
||||
_extract_gemini_image(resp)
|
||||
|
||||
def test_key_not_in_repr(self, monkeypatch):
|
||||
monkeypatch.setenv("IMAGE_API_BASE", "https://api.example.com")
|
||||
clients = build_ai_clients(plain_key="super_secret_key_xyz")
|
||||
# repr 不泄露 key
|
||||
assert "super_secret_key_xyz" not in repr(clients)
|
||||
|
||||
|
||||
# ── package_exporter ──────────────────────────────────────
|
||||
class TestPackageExporter:
|
||||
def _make_notes(self):
|
||||
return [{
|
||||
"title": "早八素颜也能有气色",
|
||||
"content": "姐妹们实测下来,轻薄不厚重是关键。✅ 通勤必备。",
|
||||
"tags": ["#素颜霜", "#日常通勤"],
|
||||
"images": [
|
||||
{"seq": 1, "role": "hook", "data": b"\xff\xd8\xff fake_jpg_1"},
|
||||
{"seq": 2, "role": "proof", "data": b"\xff\xd8\xff fake_jpg_2"},
|
||||
],
|
||||
"banned_word_status": "pass",
|
||||
}]
|
||||
|
||||
def test_creates_zip(self, tmp_path):
|
||||
zip_path = build_delivery_package(
|
||||
workspace_id=1, task_id=99,
|
||||
notes=self._make_notes(),
|
||||
base_path=str(tmp_path),
|
||||
)
|
||||
assert Path(zip_path).exists()
|
||||
assert zip_path.endswith(".zip")
|
||||
|
||||
def test_zip_contains_required_files(self, tmp_path):
|
||||
zip_path = build_delivery_package(
|
||||
workspace_id=1, task_id=99,
|
||||
notes=self._make_notes(),
|
||||
base_path=str(tmp_path),
|
||||
)
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
names = zf.namelist()
|
||||
# 应含文案.txt、发布清单、合规说明
|
||||
assert any("文案.txt" in n for n in names)
|
||||
assert any("发布清单" in n for n in names)
|
||||
assert any("合规说明" in n for n in names)
|
||||
|
||||
def test_images_named_by_seq(self, tmp_path):
|
||||
zip_path = build_delivery_package(
|
||||
workspace_id=1, task_id=99,
|
||||
notes=self._make_notes(),
|
||||
base_path=str(tmp_path),
|
||||
)
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
names = zf.namelist()
|
||||
# 图片按 seq 序号命名
|
||||
assert any("01_hook.jpg" in n for n in names)
|
||||
assert any("02_proof.jpg" in n for n in names)
|
||||
|
||||
def test_copy_txt_contains_title_and_tags(self, tmp_path):
|
||||
zip_path = build_delivery_package(
|
||||
workspace_id=1, task_id=99,
|
||||
notes=self._make_notes(),
|
||||
base_path=str(tmp_path),
|
||||
)
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
copy_txt = zf.read("note_01/文案.txt").decode("utf-8")
|
||||
assert "早八素颜也能有气色" in copy_txt
|
||||
assert "#素颜霜" in copy_txt
|
||||
|
||||
def test_compliance_note_shows_status(self, tmp_path):
|
||||
zip_path = build_delivery_package(
|
||||
workspace_id=1, task_id=99,
|
||||
notes=self._make_notes(),
|
||||
base_path=str(tmp_path),
|
||||
)
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
compliance = zf.read("✅合规说明.txt").decode("utf-8")
|
||||
assert "✅通过" in compliance
|
||||
|
||||
def test_no_images_still_creates_package(self, tmp_path):
|
||||
notes = [{**self._make_notes()[0], "images": []}]
|
||||
zip_path = build_delivery_package(
|
||||
workspace_id=1, task_id=100,
|
||||
notes=notes,
|
||||
base_path=str(tmp_path),
|
||||
)
|
||||
assert Path(zip_path).exists()
|
||||
135
backend/tests/test_flywheel_wiring.py
Normal file
135
backend/tests/test_flywheel_wiring.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
test_flywheel_wiring.py — 飞轮 Point1+Point2 验证
|
||||
Point1: 5种信号正确写入 preference_events(选文案/选图/审核 approve/reject/regenerate)
|
||||
Point2: aggregator 返回明确 prompt_fragment(含偏好角度、打回原因,workspace 严格过滤)
|
||||
无 Docker — 全程纯 Python 单元测试
|
||||
Point3+Point4 见 test_flywheel_wiring_p34.py
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
SIGNAL_WEIGHTS_MAP = {
|
||||
"text_select": 3,
|
||||
"image_select": 3,
|
||||
"approve": 5,
|
||||
"reject_with_reason": -3,
|
||||
"regenerate": -1,
|
||||
}
|
||||
|
||||
_PRODUCT = {
|
||||
"name": "素颜霜",
|
||||
"category": "护肤",
|
||||
"selling_points": ["保湿", "天然"],
|
||||
"style_tone": "素人分享风",
|
||||
"text_angles": ["成分党", "懒人必备"],
|
||||
"custom_prompt": "请保持真实素人口吻",
|
||||
}
|
||||
|
||||
_EVENTS_ENOUGH = [
|
||||
{"signal_type": "text_select", "workspace_id": 1, "product_id": 10,
|
||||
"angle_label": "成分党", "signal_weight": 3, "reason": ""},
|
||||
{"signal_type": "approve", "workspace_id": 1, "product_id": 10,
|
||||
"angle_label": "成分党", "signal_weight": 5, "reason": ""},
|
||||
{"signal_type": "text_select", "workspace_id": 1, "product_id": 10,
|
||||
"angle_label": "懒人必备", "signal_weight": 3, "reason": ""},
|
||||
{"signal_type": "reject_with_reason","workspace_id": 1, "product_id": 10,
|
||||
"angle_label": "", "signal_weight": -3, "reason": "太像广告"},
|
||||
{"signal_type": "regenerate", "workspace_id": 1, "product_id": 10,
|
||||
"angle_label": "", "signal_weight": -1, "reason": ""},
|
||||
]
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Point1: 五种信号写入结构验证
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
class TestPoint1SignalTypes:
|
||||
"""验证 5 种信号都有对应 weight,且字段完整。"""
|
||||
|
||||
def test_all_signal_types_have_weights(self):
|
||||
from app.constants.enums import SIGNAL_WEIGHTS
|
||||
required = {"text_select", "image_select", "approve", "reject_with_reason", "regenerate"}
|
||||
missing = required - set(SIGNAL_WEIGHTS.keys())
|
||||
assert not missing, f"SIGNAL_WEIGHTS 缺少: {missing}"
|
||||
|
||||
def test_positive_signals_weight_positive(self):
|
||||
from app.constants.enums import SIGNAL_WEIGHTS
|
||||
for sig in ("text_select", "image_select", "approve"):
|
||||
assert SIGNAL_WEIGHTS[sig] > 0, f"{sig} weight 应 > 0,实际={SIGNAL_WEIGHTS[sig]}"
|
||||
|
||||
def test_negative_signals_weight_negative(self):
|
||||
from app.constants.enums import SIGNAL_WEIGHTS
|
||||
for sig in ("reject_with_reason", "regenerate"):
|
||||
assert SIGNAL_WEIGHTS[sig] < 0, f"{sig} weight 应 < 0,实际={SIGNAL_WEIGHTS[sig]}"
|
||||
|
||||
def test_collect_preference_event_returns_complete_fields(self):
|
||||
from app.services.ai_engine.preference_aggregator import collect_preference_event
|
||||
event = collect_preference_event(
|
||||
signal_type="text_select",
|
||||
user_id=7,
|
||||
workspace_id=1,
|
||||
product_id=10,
|
||||
angle_label="成分党",
|
||||
)
|
||||
required_keys = {"signal_type", "signal_weight", "user_id",
|
||||
"workspace_id", "product_id", "angle_label",
|
||||
"reason", "data_ownership"}
|
||||
assert required_keys.issubset(event.keys()), f"缺字段: {required_keys - event.keys()}"
|
||||
|
||||
def test_collect_event_weight_matches_signal(self):
|
||||
from app.services.ai_engine.preference_aggregator import collect_preference_event
|
||||
ev = collect_preference_event("approve", 1, 1, 10)
|
||||
assert ev["signal_weight"] == SIGNAL_WEIGHTS_MAP["approve"]
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Point2: aggregator 返回 prompt_fragment + workspace 严格过滤
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
class TestPoint2AggregatorOutput:
|
||||
"""验证 aggregate_preference_context 返回结构与 workspace/product 隔离。"""
|
||||
|
||||
def test_returns_required_keys(self):
|
||||
from app.services.ai_engine.preference_aggregator import aggregate_preference_context
|
||||
ctx = aggregate_preference_context(_EVENTS_ENOUGH, _PRODUCT, 1, 10)
|
||||
for k in ("prompt_fragment", "recent_preference", "reject_reasons", "injected_count"):
|
||||
assert k in ctx, f"返回值缺字段: {k}"
|
||||
|
||||
def test_prompt_fragment_not_empty_with_signals(self):
|
||||
from app.services.ai_engine.preference_aggregator import aggregate_preference_context
|
||||
ctx = aggregate_preference_context(_EVENTS_ENOUGH, _PRODUCT, 1, 10)
|
||||
assert ctx["prompt_fragment"].strip(), "有足够信号时 prompt_fragment 不应为空"
|
||||
|
||||
def test_prompt_fragment_contains_top_angle(self):
|
||||
from app.services.ai_engine.preference_aggregator import aggregate_preference_context
|
||||
ctx = aggregate_preference_context(_EVENTS_ENOUGH, _PRODUCT, 1, 10)
|
||||
assert "成分党" in ctx["prompt_fragment"] # 权重最高角度
|
||||
|
||||
def test_reject_reasons_in_fragment(self):
|
||||
from app.services.ai_engine.preference_aggregator import aggregate_preference_context
|
||||
ctx = aggregate_preference_context(_EVENTS_ENOUGH, _PRODUCT, 1, 10)
|
||||
assert "太像广告" in ctx["prompt_fragment"]
|
||||
|
||||
def test_workspace_isolation_filters_other_workspace(self):
|
||||
from app.services.ai_engine.preference_aggregator import aggregate_preference_context
|
||||
mixed = _EVENTS_ENOUGH + [
|
||||
{"signal_type": "text_select", "workspace_id": 2, "product_id": 10,
|
||||
"angle_label": "workspace2专有角度", "signal_weight": 100, "reason": ""},
|
||||
]
|
||||
ctx = aggregate_preference_context(mixed, _PRODUCT, 1, 10)
|
||||
assert "workspace2专有角度" not in ctx["prompt_fragment"]
|
||||
|
||||
def test_product_isolation_filters_other_product(self):
|
||||
from app.services.ai_engine.preference_aggregator import aggregate_preference_context
|
||||
mixed = _EVENTS_ENOUGH + [
|
||||
{"signal_type": "text_select", "workspace_id": 1, "product_id": 99,
|
||||
"angle_label": "product99专有角度", "signal_weight": 100, "reason": ""},
|
||||
]
|
||||
ctx = aggregate_preference_context(mixed, _PRODUCT, 1, 10)
|
||||
assert "product99专有角度" not in ctx["prompt_fragment"]
|
||||
|
||||
def test_cold_start_when_insufficient_signals(self):
|
||||
from app.services.ai_engine.preference_aggregator import aggregate_preference_context
|
||||
from app.services.ai_engine.constants import FLYWHEEL_COLD_START
|
||||
few = _EVENTS_ENOUGH[:max(0, FLYWHEEL_COLD_START - 1)]
|
||||
ctx = aggregate_preference_context(few, _PRODUCT, 1, 10)
|
||||
assert "recent_preference" in ctx
|
||||
assert ctx["injected_count"] == len(few)
|
||||
166
backend/tests/test_flywheel_wiring_p34.py
Normal file
166
backend/tests/test_flywheel_wiring_p34.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
test_flywheel_wiring_p34.py — 飞轮 Point3+Point4 验证
|
||||
Point3: pipeline 把 prompt_fragment 注入 user_prompt(prompt trace 含片段)
|
||||
Point4: UI 层 flywheel_injected SSE 事件含 recent_preference + reject_reasons
|
||||
无 Docker — 全程纯 Python 单元测试
|
||||
Point1+Point2 见 test_flywheel_wiring.py
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
_PRODUCT = {
|
||||
"name": "素颜霜",
|
||||
"category": "护肤",
|
||||
"selling_points": ["保湿", "天然"],
|
||||
"style_tone": "素人分享风",
|
||||
"text_angles": ["成分党", "懒人必备"],
|
||||
"custom_prompt": "请保持真实素人口吻",
|
||||
}
|
||||
|
||||
_EVENTS_ENOUGH = [
|
||||
{"signal_type": "text_select", "workspace_id": 1, "product_id": 10,
|
||||
"angle_label": "成分党", "signal_weight": 3, "reason": ""},
|
||||
{"signal_type": "approve", "workspace_id": 1, "product_id": 10,
|
||||
"angle_label": "成分党", "signal_weight": 5, "reason": ""},
|
||||
{"signal_type": "text_select", "workspace_id": 1, "product_id": 10,
|
||||
"angle_label": "懒人必备", "signal_weight": 3, "reason": ""},
|
||||
{"signal_type": "reject_with_reason","workspace_id": 1, "product_id": 10,
|
||||
"angle_label": "", "signal_weight": -3, "reason": "太像广告"},
|
||||
{"signal_type": "regenerate", "workspace_id": 1, "product_id": 10,
|
||||
"angle_label": "", "signal_weight": -1, "reason": ""},
|
||||
]
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Point3: pipeline 把 prompt_fragment 注入 user_prompt
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
class TestPoint3PromptInjection:
|
||||
"""验证 compose_variants 把 flywheel_context 正确注入 user_prompt。"""
|
||||
|
||||
def test_compose_variants_appends_flywheel_to_user_prompt(self):
|
||||
from app.services.ai_engine.prompt_composer import compose_variants
|
||||
fragment = "【偏好角度参考】历史选择偏好:成分党"
|
||||
_, user_prompt = compose_variants(_PRODUCT, 3, flywheel_context=fragment)
|
||||
assert fragment in user_prompt, "flywheel_context 应出现在 user_prompt 中"
|
||||
|
||||
def test_compose_variants_flywheel_not_in_system_prompt(self):
|
||||
"""飞轮片段不应覆盖 system_prompt(COPY_SYSTEM 质量护栏不能被干扰)"""
|
||||
from app.services.ai_engine.prompt_composer import compose_variants
|
||||
fragment = "飞轮注入内容UNIQUE123"
|
||||
system_prompt, _ = compose_variants(_PRODUCT, 2, flywheel_context=fragment)
|
||||
assert fragment not in system_prompt, "飞轮片段不应出现在 system_prompt"
|
||||
|
||||
def test_compose_variants_empty_flywheel_still_valid(self):
|
||||
"""无飞轮片段时 compose_variants 仍正常返回 (system, user)"""
|
||||
from app.services.ai_engine.prompt_composer import compose_variants
|
||||
system_p, user_p = compose_variants(_PRODUCT, 3, flywheel_context="")
|
||||
assert isinstance(system_p, str) and len(system_p) > 0
|
||||
assert isinstance(user_p, str) and len(user_p) > 0
|
||||
|
||||
def test_pipeline_fragment_contains_top_angle(self):
|
||||
"""pipeline 重现:ORM events → aggregate → fragment 含最高权重角度"""
|
||||
from app.services.ai_engine.preference_aggregator import aggregate_preference_context
|
||||
|
||||
def make_event(sig, angle, weight, reason):
|
||||
e = MagicMock()
|
||||
e.signal_type = sig
|
||||
e.workspace_id = 1
|
||||
e.product_id = 10
|
||||
e.angle_label = angle
|
||||
e.signal_weight = weight
|
||||
e.reason = reason
|
||||
return e
|
||||
|
||||
mock_events = [
|
||||
make_event("text_select", "成分党", 3, ""),
|
||||
make_event("approve", "成分党", 5, ""),
|
||||
make_event("reject_with_reason", "", -3, "太像广告"),
|
||||
make_event("text_select", "懒人必备", 3, ""),
|
||||
make_event("regenerate", "", -1, ""),
|
||||
]
|
||||
events_dicts = [
|
||||
{"signal_type": e.signal_type, "workspace_id": e.workspace_id,
|
||||
"product_id": e.product_id, "angle_label": e.angle_label or "",
|
||||
"signal_weight": e.signal_weight, "reason": e.reason or ""}
|
||||
for e in mock_events
|
||||
]
|
||||
ctx = aggregate_preference_context(events_dicts, _PRODUCT, 1, 10)
|
||||
fragment = ctx.get("prompt_fragment", "")
|
||||
assert isinstance(fragment, str)
|
||||
assert "成分党" in fragment
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Point4: UI 层 flywheel_injected SSE 事件结构验证
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
class TestPoint4SseFlywheelEvent:
|
||||
"""验证 flywheel_injected SSE 事件包含 UI 展示所需字段。"""
|
||||
|
||||
# FE useSse.ts flywheel_injected 事件期望字段
|
||||
FE_EXPECTED_FIELDS = {"recent_preference", "reject_reasons"}
|
||||
|
||||
def test_aggregator_output_matches_sse_fields(self):
|
||||
from app.services.ai_engine.preference_aggregator import aggregate_preference_context
|
||||
ctx = aggregate_preference_context(_EVENTS_ENOUGH, _PRODUCT, 1, 10)
|
||||
missing = self.FE_EXPECTED_FIELDS - ctx.keys()
|
||||
assert not missing, f"SSE flywheel_injected 缺少字段: {missing}"
|
||||
|
||||
def test_recent_preference_is_nonempty_string(self):
|
||||
from app.services.ai_engine.preference_aggregator import aggregate_preference_context
|
||||
ctx = aggregate_preference_context(_EVENTS_ENOUGH, _PRODUCT, 1, 10)
|
||||
assert isinstance(ctx["recent_preference"], str) and ctx["recent_preference"].strip()
|
||||
|
||||
def test_reject_reasons_is_list_of_strings(self):
|
||||
from app.services.ai_engine.preference_aggregator import aggregate_preference_context
|
||||
ctx = aggregate_preference_context(_EVENTS_ENOUGH, _PRODUCT, 1, 10)
|
||||
assert isinstance(ctx["reject_reasons"], list)
|
||||
for r in ctx["reject_reasons"]:
|
||||
assert isinstance(r, str)
|
||||
|
||||
def test_reject_reasons_max_3(self):
|
||||
"""打回原因最多返回3条(FE 卡片布局限制)"""
|
||||
from app.services.ai_engine.preference_aggregator import aggregate_preference_context
|
||||
many_rejects = [
|
||||
{"signal_type": "reject_with_reason", "workspace_id": 1,
|
||||
"product_id": 10, "angle_label": "", "signal_weight": -3,
|
||||
"reason": f"原因{i}"}
|
||||
for i in range(10)
|
||||
] + _EVENTS_ENOUGH
|
||||
ctx = aggregate_preference_context(many_rejects, _PRODUCT, 1, 10)
|
||||
assert len(ctx["reject_reasons"]) <= 3
|
||||
|
||||
def test_flywheel_service_get_context_returns_sse_compatible_shape(self):
|
||||
"""flywheel_service.get_preference_context 返回结构与 FE PreferenceContext DTO 一致"""
|
||||
from app.services.flywheel_service import get_preference_context
|
||||
from app.constants.enums import SignalType
|
||||
|
||||
def make_orm(sig, angle, reason):
|
||||
e = MagicMock()
|
||||
e.signal_type = sig
|
||||
e.angle_label = angle
|
||||
e.reason = reason
|
||||
return e
|
||||
|
||||
orm_events = [
|
||||
make_orm(SignalType.TEXT_SELECT, "成分党", ""),
|
||||
make_orm(SignalType.TEXT_SELECT, "成分党", ""),
|
||||
make_orm(SignalType.APPROVE, "成分党", ""),
|
||||
make_orm(SignalType.REJECT_WITH_REASON, "", "太像广告"),
|
||||
make_orm(SignalType.TEXT_SELECT, "懒人必备", ""),
|
||||
]
|
||||
mock_db = MagicMock()
|
||||
mq = MagicMock()
|
||||
mock_db.query.return_value = mq
|
||||
mq.filter.return_value = mq
|
||||
mq.order_by.return_value = mq
|
||||
mq.limit.return_value = mq
|
||||
mq.all.return_value = orm_events
|
||||
|
||||
result = get_preference_context(mock_db, workspace_id=1, product_id=10)
|
||||
|
||||
fe_dto_keys = {"recent_preference", "reject_reasons", "injected_count"}
|
||||
missing = fe_dto_keys - result.keys()
|
||||
assert not missing, f"FE PreferenceContext DTO 缺字段: {missing}"
|
||||
assert isinstance(result["recent_preference"], str)
|
||||
assert isinstance(result["reject_reasons"], list)
|
||||
assert isinstance(result["injected_count"], int)
|
||||
241
backend/tests/test_integration_seams.py
Normal file
241
backend/tests/test_integration_seams.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""
|
||||
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()) # 不应抛异常
|
||||
Reference in New Issue
Block a user