test(points): hold the fake and the Qdrant adapter to one shared contract
Why: - Two parallel test files let a fake drift more permissive than the store it stands in for, so unit tests stay green while production diverges. Plan 002 Phase 1's exit criterion is precisely that the two agree. Changes: - One scenario suite in tests/support/point_contract.py, run against FakePointRepository (unit) and QdrantPointRepository (integration). A divergence fails one of the two runs rather than hiding. - The fake models the behaviours services branch on: the implied is_active read filter, value-based cursor pagination, and a stale version guard that matches nothing rather than raising -- the no-op Qdrant's filtered set_payload actually has, and the reason a service must read back to know its write landed. - Patched points are re-validated rather than model_copy'd, so the fake holds a datetime where a read from real Qdrant returns one. - The seeded corpus gives each tenant its own file: point IDs derive from file_id plus chunk_index alone, so two tenants in one file would collide on a single ID and the fixture would assert an impossible state. Impact: - 15 scenarios pass against both implementations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
112
tests/integration/qdrant/test_point_repository.py
Normal file
112
tests/integration/qdrant/test_point_repository.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""`QdrantPointRepository` against the shared `PointRepository` contract.
|
||||
|
||||
The identical scenarios run against the in-memory fake in
|
||||
`tests/unit/application/points/test_point_repository_contract.py`. This file is
|
||||
the half that decides whether the fake is telling the truth: filter
|
||||
construction, `order_by: order_id` scroll, cursor pagination, full-text
|
||||
matching, and the filtered `set_payload` version guard are all Qdrant
|
||||
behaviours a fake can only approximate, and plan 002 Phase 1's exit criterion is
|
||||
that the approximation holds.
|
||||
|
||||
Seeding differs from the fake's (real points need vectors), so it lives here;
|
||||
everything asserted lives in `tests/support/point_contract.py`.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
import pytest
|
||||
from qdrant_client import AsyncQdrantClient, models
|
||||
|
||||
from src.application.ingestion.models import SparseVector
|
||||
from src.application.points.models import ChunkPoint
|
||||
from src.config import QdrantSettings
|
||||
from src.infrastructure.qdrant.collection import (
|
||||
DENSE_NOMIC_DIMENSIONS,
|
||||
DENSE_OPENAI_DIMENSIONS,
|
||||
ensure_chunks_collection,
|
||||
)
|
||||
from src.infrastructure.qdrant.point_repository import QdrantPointRepository
|
||||
from src.infrastructure.qdrant.points import QdrantPointStorage
|
||||
from tests.support import point_contract
|
||||
from tests.support.point_contract import SeedSpec, build_point, seed_specs
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.integration,
|
||||
pytest.mark.qdrant,
|
||||
pytest.mark.asyncio(loop_scope="session"),
|
||||
]
|
||||
|
||||
type Scenario = Callable[..., Awaitable[None]]
|
||||
|
||||
TENANT_A = uuid.UUID("11111111-1111-4111-8111-111111111111")
|
||||
TENANT_B = uuid.UUID("22222222-2222-4222-8222-222222222222")
|
||||
FILE_A = uuid.UUID("33333333-3333-4333-8333-333333333333")
|
||||
FILE_B = uuid.UUID("44444444-4444-4444-8444-444444444444")
|
||||
FILE_C = uuid.UUID("55555555-5555-4555-8555-555555555555")
|
||||
|
||||
IDS = {
|
||||
"tenant_a": TENANT_A,
|
||||
"tenant_b": TENANT_B,
|
||||
"file_a": FILE_A,
|
||||
"file_b": FILE_B,
|
||||
"file_c": FILE_C,
|
||||
}
|
||||
|
||||
|
||||
def _chunk_point(spec: SeedSpec) -> ChunkPoint:
|
||||
"""The contract's `Point` re-expressed as something upsertable.
|
||||
|
||||
`build_point` produces the read model; Qdrant needs vectors and a flat
|
||||
payload dict, so the payload is taken straight off the model to guarantee
|
||||
the two representations cannot drift apart.
|
||||
"""
|
||||
point = build_point(spec)
|
||||
payload = point.model_dump(mode="json", exclude={"point_id", "vectors"})
|
||||
return ChunkPoint(
|
||||
point_id=point.point_id,
|
||||
dense={
|
||||
"dense_nomic": [0.1] * DENSE_NOMIC_DIMENSIONS,
|
||||
"dense_openai": [0.2] * DENSE_OPENAI_DIMENSIONS,
|
||||
},
|
||||
sparse=SparseVector(indices=[1, 2], values=[0.5, 0.25]),
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
async def _seeded(
|
||||
qdrant_client: AsyncQdrantClient, qdrant_settings: QdrantSettings
|
||||
) -> QdrantPointRepository:
|
||||
await ensure_chunks_collection(qdrant_client, collection=qdrant_settings.collection)
|
||||
storage = QdrantPointStorage(qdrant_client, collection=qdrant_settings.collection)
|
||||
await storage.upsert_points(
|
||||
[_chunk_point(spec) for spec in seed_specs(TENANT_A, TENANT_B, FILE_A, FILE_B, FILE_C)]
|
||||
)
|
||||
# Payload indexes are built asynchronously; without waiting, a full-text or
|
||||
# ordered query can run against a half-built index and return short.
|
||||
await _await_indexing(qdrant_client, qdrant_settings.collection)
|
||||
return QdrantPointRepository(qdrant_client, collection=qdrant_settings.collection)
|
||||
|
||||
|
||||
async def _await_indexing(client: AsyncQdrantClient, collection: str) -> None:
|
||||
for _ in range(100):
|
||||
info = await client.get_collection(collection)
|
||||
if info.status == models.CollectionStatus.GREEN and info.indexed_vectors_count is not None:
|
||||
return
|
||||
raise AssertionError(f"collection {collection!r} did not finish indexing")
|
||||
|
||||
|
||||
def _arguments(scenario: Scenario) -> dict[str, uuid.UUID]:
|
||||
return {name: value for name, value in IDS.items() if name in scenario.__annotations__}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"scenario",
|
||||
[*point_contract.READ_SCENARIOS, *point_contract.WRITE_SCENARIOS],
|
||||
ids=lambda scenario: scenario.__name__.removeprefix("scenario_"),
|
||||
)
|
||||
async def test_qdrant_point_repository_satisfies_the_contract(
|
||||
scenario: Scenario, qdrant_client: AsyncQdrantClient, qdrant_settings: QdrantSettings
|
||||
) -> None:
|
||||
repository = await _seeded(qdrant_client, qdrant_settings)
|
||||
await scenario(repository, **_arguments(scenario))
|
||||
Reference in New Issue
Block a user