- gateway_client: guard token cache with a lazy anyio.Lock so concurrent embed calls share one M2M token request instead of racing - status subscriber: distinguish idle fetch timeouts from real broker errors (log + 5s backoff) instead of swallowing all and spinning - nats: warn when the bus URL uses unencrypted transport (non-tls://) - collection_metadata: accept an optional shared httpx client, make TLS verify explicit, document the unauthenticated control-plane contract - replace python -O-stripped asserts with explicit ValueError in the bus status builder and the api metadata source - document why the nil-UUID sentinel point can't collide with content ids Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
115 lines
4.1 KiB
Python
115 lines
4.1 KiB
Python
"""Collection metadata source + sentinel (design §10.1)."""
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import httpx
|
|
|
|
from nextcloud_mcp_server.config import Settings
|
|
from nextcloud_mcp_server.vector import collection_metadata as cm
|
|
from nextcloud_mcp_server.vector.payload_keys import EMBEDDING_IDENTITY
|
|
|
|
|
|
async def test_qdrant_read_hit(mocker):
|
|
client = mocker.AsyncMock()
|
|
client.retrieve.return_value = [
|
|
SimpleNamespace(
|
|
payload={
|
|
EMBEDDING_IDENTITY: "mistral-embed",
|
|
cm.CHUNKING_CONFIG: {"chunk_size": 1024, "chunk_overlap": 100},
|
|
cm.IS_SENTINEL: True,
|
|
}
|
|
)
|
|
]
|
|
meta = await cm.read_collection_metadata(client, "col", Settings())
|
|
assert meta["embedding_identity"] == "mistral-embed"
|
|
assert meta["chunking_config"]["chunk_size"] == 1024
|
|
|
|
|
|
async def test_qdrant_miss_falls_back_to_env(mocker):
|
|
client = mocker.AsyncMock()
|
|
client.retrieve.return_value = [] # no sentinel
|
|
settings = Settings(document_chunk_size=2048, document_chunk_overlap=200)
|
|
meta = await cm.read_collection_metadata(client, "col", settings)
|
|
assert meta == cm.env_default_metadata(settings)
|
|
assert meta["chunking_config"]["chunk_size"] == 2048
|
|
|
|
|
|
async def test_qdrant_error_falls_back_to_env(mocker):
|
|
client = mocker.AsyncMock()
|
|
client.retrieve.side_effect = RuntimeError("qdrant down")
|
|
settings = Settings()
|
|
meta = await cm.read_collection_metadata(client, "col", settings)
|
|
assert meta == cm.env_default_metadata(settings)
|
|
|
|
|
|
async def test_api_source(mocker):
|
|
settings = Settings(
|
|
collection_metadata_source="api",
|
|
collection_metadata_api_url="https://cp",
|
|
)
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
assert request.url.path == "/v1/qdrant-collections/col/metadata"
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"embedding_identity": "amazon.titan-embed-text-v2:0",
|
|
"chunking_config": {"chunk_size": 512, "chunk_overlap": 50},
|
|
},
|
|
)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
orig = httpx.AsyncClient
|
|
mocker.patch.object(
|
|
httpx,
|
|
"AsyncClient",
|
|
lambda *a, **k: orig(*a, **{**k, "transport": transport}),
|
|
)
|
|
|
|
meta = await cm.read_collection_metadata(mocker.AsyncMock(), "col", settings)
|
|
assert meta["embedding_identity"] == "amazon.titan-embed-text-v2:0"
|
|
|
|
|
|
async def test_api_source_uses_shared_http_client():
|
|
"""When a caller passes http_client, the read reuses it (no own client)."""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
assert request.url.path == "/v1/qdrant-collections/col/metadata"
|
|
return httpx.Response(200, json={"embedding_identity": "mistral-embed"})
|
|
|
|
shared = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
|
async with shared:
|
|
meta = await cm._read_from_api("https://cp", "col", client=shared)
|
|
assert meta["embedding_identity"] == "mistral-embed"
|
|
|
|
|
|
async def test_api_missing_url_falls_back_to_env(mocker):
|
|
"""COLLECTION_METADATA_SOURCE=api with no URL must not crash the query path."""
|
|
# Bypass Settings validation to simulate a -O / mutated-state edge case.
|
|
settings = Settings()
|
|
settings.collection_metadata_source = "api"
|
|
settings.collection_metadata_api_url = None
|
|
meta = await cm.read_collection_metadata(mocker.AsyncMock(), "col", settings)
|
|
assert meta == cm.env_default_metadata(settings)
|
|
|
|
|
|
async def test_upsert_sentinel_builds_point(mocker):
|
|
client = mocker.AsyncMock()
|
|
await cm.upsert_sentinel(
|
|
client,
|
|
"col",
|
|
embedding_identity="mistral-embed",
|
|
chunking_config={"chunk_size": 2048, "chunk_overlap": 200},
|
|
dimension=4,
|
|
)
|
|
client.upsert.assert_awaited_once()
|
|
kwargs = client.upsert.await_args.kwargs
|
|
assert kwargs["collection_name"] == "col"
|
|
point = kwargs["points"][0]
|
|
assert str(point.id) == cm.SENTINEL_POINT_ID
|
|
# Non-zero dense (cosine-safe), empty sparse.
|
|
assert point.vector["dense"][0] > 0.0
|
|
assert len(point.vector["dense"]) == 4
|
|
assert point.payload[EMBEDDING_IDENTITY] == "mistral-embed"
|
|
assert point.payload[cm.IS_SENTINEL] is True
|