"""`MilvusProfileProjection` 的定向测试。 前 4 个用例移植自同事 `ZSY_develop` 的 `tests/unit/infrastructure/test_milvus_profile_projection.py`; 后 4 个覆盖本仓对其做的**两处契约放宽**(`customer_id` 兼容字符串、 不可投影键跳过而非整批失败)与脱敏,这些是移植时必须钉住的差异点。 """ from uuid import uuid4 import pytest from app.core.errors import RecoverableAgentError from app.infrastructure.milvus_profile_projection import MilvusProfileProjection class FakeMilvus: def __init__(self, existing: list[dict[str, object]] | None = None) -> None: self.existing = existing or [] self.queries: list[dict[str, object]] = [] self.upserts: list[dict[str, object]] = [] async def query(self, **kwargs: object) -> list[dict[str, object]]: self.queries.append(kwargs) return self.existing async def upsert(self, **kwargs: object) -> None: self.upserts.append(kwargs) def payload() -> dict[str, object]: return { "customer_id": 7, "profile_version": 1, "memory_sources": [{ "memory_uuid": str(uuid4()), "memory_key": "preference:risk_level", "content": "稳健型", "memory_type": "preference", "confidence": 0.9, "version": 2, "valid_until": None, }], } def _source(data: dict[str, object]) -> dict[str, object]: sources = data["memory_sources"] assert isinstance(sources, list) source = sources[0] assert isinstance(source, dict) return source @pytest.mark.asyncio async def test_upsert_writes_schema_fields_and_vector() -> None: client = FakeMilvus() projection = MilvusProfileProjection(client, _embed) await projection.upsert(payload()) assert len(client.upserts) == 1 row = client.upserts[0]["data"][0] assert row["customer_id"] == 7 assert row["status"] == "active" assert len(row["embedding"]) == 1024 @pytest.mark.asyncio async def test_lower_memory_version_is_not_overwritten() -> None: data = payload() memory_uuid = _source(data)["memory_uuid"] client = FakeMilvus(existing=[{ "memory_uuid": memory_uuid, "customer_id": 7, "version": 3, }]) await MilvusProfileProjection(client, _embed).upsert(data) assert client.upserts == [] @pytest.mark.asyncio async def test_embedding_dimension_is_enforced() -> None: with pytest.raises(RecoverableAgentError, match="维度"): await MilvusProfileProjection(client=FakeMilvus(), embed=_embed_short).upsert( payload() ) @pytest.mark.asyncio async def test_non_uuid_memory_id_is_rejected() -> None: data = payload() _source(data)["memory_uuid"] = "unsafe\" or true" with pytest.raises(ValueError, match="memory_uuid"): await MilvusProfileProjection(FakeMilvus(), _embed).upsert(data) # --- 本仓放宽的契约(移植差异点) ------------------------------------------- @pytest.mark.asyncio async def test_string_customer_id_is_accepted() -> None: """本仓三处生产者写的都是 `str(customer_id)`;不接受字符串则事件必然全部失败。""" data = payload() data["customer_id"] = "9102" client = FakeMilvus() await MilvusProfileProjection(client, _embed).upsert(data) assert client.upserts[0]["data"][0]["customer_id"] == 9102 @pytest.mark.asyncio async def test_non_numeric_customer_id_is_rejected() -> None: """放宽不等于不校验:uuid 之类的非数字串必须拒绝,不能当成客户号写进向量库。""" data = payload() data["customer_id"] = "957c0552-7fa2-4f2a-924d-d2d2e133b245" with pytest.raises(ValueError, match="customer_id"): await MilvusProfileProjection(FakeMilvus(), _embed).upsert(data) @pytest.mark.asyncio async def test_version_key_fallback_is_supported() -> None: """本仓生产端 payload 用 `version`;适配器契约用 `profile_version`。两个都要认。""" data = payload() del data["profile_version"] data["version"] = 5 client = FakeMilvus() await MilvusProfileProjection(client, _embed).upsert(data) assert len(client.upserts) == 1 @pytest.mark.asyncio async def test_non_projectable_memory_key_is_skipped_not_fatal() -> None: """`constraint:*` / `profile:*` 不在可投影前缀内。 关键:一条不可投影的键**不得**毒死同一客户其余可投影记忆。 """ data = payload() good = _source(data) data["memory_sources"] = [ { "memory_uuid": str(uuid4()), "memory_key": "constraint:liquidity", "content": "半年内需要流动性", "memory_type": "constraint", "confidence": 0.8, "version": 1, "valid_until": None, }, good, ] client = FakeMilvus() await MilvusProfileProjection(client, _embed).upsert(data) assert len(client.upserts) == 1 rows = client.upserts[0]["data"] assert [row["memory_key"] for row in rows] == ["preference:risk_level"] @pytest.mark.asyncio async def test_all_keys_non_projectable_writes_nothing() -> None: """全部不可投影时不写 Milvus,但也不报错(不是失败,是无需投影)。""" data = payload() _source(data)["memory_key"] = "profile:occupation" client = FakeMilvus() await MilvusProfileProjection(client, _embed).upsert(data) assert client.upserts == [] @pytest.mark.asyncio async def test_sensitive_credentials_are_sanitized_before_write() -> None: """落外部存储前必须脱敏:手机号不得原样写进向量库。""" data = payload() _source(data)["content"] = "我的手机号是 15936583816,稳健型" client = FakeMilvus() await MilvusProfileProjection(client, _embed).upsert(data) content = client.upserts[0]["data"][0]["content"] assert "15936583816" not in content assert "[手机号已隐藏]" in content def _vector(size: int = 1024) -> list[float]: return [0.0] * size async def _embed(_: str) -> list[float]: return _vector() async def _embed_short(_: str) -> list[float]: return _vector(3)