133 lines
5.2 KiB
Python
133 lines
5.2 KiB
Python
"""T21-1 embedding 服务单测:embed_texts / embed_text(全部 mock HTTP,不依赖 Ollama)。
|
||
|
||
覆盖四类:
|
||
1. 成功语义:批量顺序一一对应、维度校验通过、空列表短路不发请求;
|
||
2. 失败语义:连接拒绝 / 非 200(模型未拉取 404)/ 响应结构异常 → EmbeddingError;
|
||
3. 维度防线:维度不符(8 维假数据)必须报错——防静默污染检索(拍板口径);
|
||
4. 便捷方法:embed_text 等价 embed_texts([text])[0]。
|
||
|
||
httpx.MockTransport 注入:_client() 是 monkeypatch 点(settings 超时透传)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import httpx
|
||
import pytest
|
||
|
||
from app.config.settings import settings
|
||
from app.service import embedding as emb
|
||
|
||
|
||
def _mock_client(handler) -> httpx.Client:
|
||
"""构造 mock 传输层的 httpx.Client(替换真实网络)。"""
|
||
return httpx.Client(transport=httpx.MockTransport(handler), timeout=5)
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _patch_client(monkeypatch):
|
||
"""默认给一个「合法 1024 维响应」的 mock 客户端,各用例按需覆盖。"""
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
body = request.read()
|
||
import json
|
||
|
||
payload = json.loads(body)
|
||
vectors = [[0.1] * settings.embed_dim for _ in payload["input"]]
|
||
return httpx.Response(200, json={"model": payload["model"], "embeddings": vectors})
|
||
|
||
monkeypatch.setattr(emb, "_client", lambda: _mock_client(handler))
|
||
|
||
|
||
class TestSuccess:
|
||
"""成功语义。"""
|
||
|
||
def test_batch_order_preserved(self, monkeypatch):
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
import json
|
||
|
||
payload = json.loads(request.read())
|
||
# 每条向量以首字符标记,断言顺序一一对应
|
||
vectors = [[float(ord(t[0]))] + [0.0] * (settings.embed_dim - 1) for t in payload["input"]]
|
||
return httpx.Response(200, json={"embeddings": vectors})
|
||
|
||
monkeypatch.setattr(emb, "_client", lambda: _mock_client(handler))
|
||
out = emb.embed_texts(["甲文本", "乙文本", "丙文本"])
|
||
assert len(out) == 3
|
||
assert out[0][0] == float(ord("甲"))
|
||
assert out[2][0] == float(ord("丙"))
|
||
|
||
def test_empty_input_short_circuits(self, monkeypatch):
|
||
# 空列表不发请求:handler 一旦被调用就 fail(返回 500 触发错误)
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
raise AssertionError("空输入不应发起 HTTP 请求")
|
||
|
||
monkeypatch.setattr(emb, "_client", lambda: _mock_client(handler))
|
||
assert emb.embed_texts([]) == []
|
||
|
||
def test_request_payload_uses_settings_model(self, monkeypatch):
|
||
captured: dict = {}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
import json
|
||
|
||
captured.update(json.loads(request.read()))
|
||
payload = json.loads(request.read())
|
||
vectors = [[0.0] * settings.embed_dim for _ in payload["input"]]
|
||
return httpx.Response(200, json={"embeddings": vectors})
|
||
|
||
monkeypatch.setattr(emb, "_client", lambda: _mock_client(handler))
|
||
emb.embed_texts(["测试"])
|
||
assert captured["model"] == settings.embed_model
|
||
assert captured["input"] == ["测试"]
|
||
|
||
|
||
class TestFailure:
|
||
"""失败语义:一律 EmbeddingError,不静默降级。"""
|
||
|
||
def test_connection_refused(self, monkeypatch):
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
raise httpx.ConnectError("connection refused")
|
||
|
||
monkeypatch.setattr(emb, "_client", lambda: _mock_client(handler))
|
||
with pytest.raises(emb.EmbeddingError, match="连接失败"):
|
||
emb.embed_texts(["任意"])
|
||
|
||
def test_model_not_found_404(self, monkeypatch):
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
return httpx.Response(404, json={"error": "model 'bge-m3' not found"})
|
||
|
||
monkeypatch.setattr(emb, "_client", lambda: _mock_client(handler))
|
||
with pytest.raises(emb.EmbeddingError, match="HTTP 404"):
|
||
emb.embed_texts(["任意"])
|
||
|
||
def test_malformed_response(self, monkeypatch):
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
# embeddings 缺失 / 数量不匹配均属结构异常
|
||
return httpx.Response(200, json={"embeddings": [[0.0] * settings.embed_dim]})
|
||
|
||
monkeypatch.setattr(emb, "_client", lambda: _mock_client(handler))
|
||
with pytest.raises(emb.EmbeddingError, match="数量与输入不匹配"):
|
||
emb.embed_texts(["一", "二"])
|
||
|
||
|
||
class TestDimensionGuard:
|
||
"""维度防线(拍板:维度不符必须报错)。"""
|
||
|
||
def test_wrong_dimension_rejected(self, monkeypatch):
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
import json
|
||
|
||
payload = json.loads(request.read())
|
||
vectors = [[0.1] * 8 for _ in payload["input"]] # 假 8 维
|
||
return httpx.Response(200, json={"embeddings": vectors})
|
||
|
||
monkeypatch.setattr(emb, "_client", lambda: _mock_client(handler))
|
||
with pytest.raises(emb.EmbeddingError, match="维度不符"):
|
||
emb.embed_texts(["任意"])
|
||
|
||
|
||
class TestConvenience:
|
||
def test_embed_text_single(self):
|
||
out = emb.embed_text("单条")
|
||
assert len(out) == settings.embed_dim
|