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:
160
tests/fakes.py
160
tests/fakes.py
@@ -8,6 +8,8 @@ from datetime import datetime
|
||||
|
||||
from src.application.ingestion.models import SparseVector
|
||||
from src.application.points.models import ChunkPoint
|
||||
from src.application.points.point import Point
|
||||
from src.application.ports.point_repository import PayloadPatch, PointPage
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -139,3 +141,161 @@ class FakePointStorage:
|
||||
point.payload["is_active"] = False
|
||||
point.payload["deleted_at"] = deleted_at.isoformat()
|
||||
return len(stale)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakePointRepository:
|
||||
"""An in-memory `PointRepository`.
|
||||
|
||||
Stores `Point` models keyed by point id. The filter, ordering, and version
|
||||
semantics below are reimplemented in Python rather than stubbed, because
|
||||
every service this fake stands behind is being tested *for* those semantics:
|
||||
a stub that returned points in insertion order would pass a reorder test
|
||||
that real Qdrant fails.
|
||||
|
||||
Three behaviours are modelled deliberately:
|
||||
|
||||
- `is_active` is filtered out unless `include_inactive`, matching ADR-0002's
|
||||
implied read filter.
|
||||
- `list_by_file` sorts by `order_id` and paginates by value, the same cursor
|
||||
mechanism the Qdrant adapter uses, so a test can catch a cursor that skips
|
||||
or repeats.
|
||||
- `apply_patches` honours `expected_version` by silently matching nothing on
|
||||
a mismatch — the no-op-not-error behaviour Qdrant's filtered `set_payload`
|
||||
actually has, which is what makes the service's post-check necessary.
|
||||
"""
|
||||
|
||||
points: dict[str, Point] = field(default_factory=dict)
|
||||
patch_batches: list[list[PayloadPatch]] = field(default_factory=list)
|
||||
fail_next: bool = False
|
||||
|
||||
def add(self, point: Point) -> None:
|
||||
"""Seed a point. Test-only helper, not part of the port."""
|
||||
self.points[str(point.point_id)] = point
|
||||
|
||||
def _visible(
|
||||
self,
|
||||
*,
|
||||
tenant_id: uuid.UUID,
|
||||
domain: str | None = None,
|
||||
file_id: uuid.UUID | None = None,
|
||||
include_inactive: bool = False,
|
||||
) -> list[Point]:
|
||||
return [
|
||||
point
|
||||
for point in self.points.values()
|
||||
if point.tenant_id == tenant_id
|
||||
and (domain is None or point.domain == domain)
|
||||
and (file_id is None or point.file_id == file_id)
|
||||
and (include_inactive or point.is_active)
|
||||
]
|
||||
|
||||
async def get(
|
||||
self, *, tenant_id: uuid.UUID, point_id: uuid.UUID, with_vectors: bool = False
|
||||
) -> Point | None:
|
||||
point = self.points.get(str(point_id))
|
||||
if point is None or point.tenant_id != tenant_id:
|
||||
return None
|
||||
return point if with_vectors else point.model_copy(update={"vectors": None})
|
||||
|
||||
async def get_many(
|
||||
self, *, tenant_id: uuid.UUID, point_ids: Sequence[uuid.UUID]
|
||||
) -> tuple[Point, ...]:
|
||||
wanted = {str(point_id) for point_id in point_ids}
|
||||
return tuple(
|
||||
point
|
||||
for key, point in self.points.items()
|
||||
if key in wanted and point.tenant_id == tenant_id
|
||||
)
|
||||
|
||||
async def list_by_file(
|
||||
self,
|
||||
*,
|
||||
tenant_id: uuid.UUID,
|
||||
file_id: uuid.UUID,
|
||||
limit: int,
|
||||
cursor: str | None = None,
|
||||
include_inactive: bool = False,
|
||||
) -> PointPage:
|
||||
ordered = sorted(
|
||||
self._visible(tenant_id=tenant_id, file_id=file_id, include_inactive=include_inactive),
|
||||
key=lambda point: point.order_id,
|
||||
)
|
||||
if cursor is not None:
|
||||
ordered = [point for point in ordered if point.order_id > float(cursor)]
|
||||
|
||||
page = tuple(ordered[:limit])
|
||||
next_cursor = repr(page[-1].order_id) if len(page) == limit else None
|
||||
return PointPage(points=page, next_cursor=next_cursor)
|
||||
|
||||
async def count(
|
||||
self,
|
||||
*,
|
||||
tenant_id: uuid.UUID,
|
||||
domain: str | None = None,
|
||||
file_id: uuid.UUID | None = None,
|
||||
include_inactive: bool = False,
|
||||
) -> int:
|
||||
return len(
|
||||
self._visible(
|
||||
tenant_id=tenant_id,
|
||||
domain=domain,
|
||||
file_id=file_id,
|
||||
include_inactive=include_inactive,
|
||||
)
|
||||
)
|
||||
|
||||
async def keyword_search(
|
||||
self,
|
||||
*,
|
||||
tenant_id: uuid.UUID,
|
||||
query: str,
|
||||
limit: int,
|
||||
cursor: str | None = None,
|
||||
domain: str | None = None,
|
||||
file_id: uuid.UUID | None = None,
|
||||
include_inactive: bool = False,
|
||||
) -> PointPage:
|
||||
# Whole-token containment, lowercased: an approximation of Qdrant's
|
||||
# full-text index good enough to tell "matched" from "did not", which is
|
||||
# all the service layer branches on. Ranking is not modelled because the
|
||||
# real index does not rank either (ADR-0002).
|
||||
terms = query.lower().split()
|
||||
matches = [
|
||||
point
|
||||
for point in self._visible(
|
||||
tenant_id=tenant_id,
|
||||
domain=domain,
|
||||
file_id=file_id,
|
||||
include_inactive=include_inactive,
|
||||
)
|
||||
if all(term in point.content.lower().split() for term in terms)
|
||||
]
|
||||
start = int(cursor) if cursor is not None else 0
|
||||
page = tuple(matches[start : start + limit])
|
||||
next_start = start + limit
|
||||
return PointPage(
|
||||
points=page, next_cursor=str(next_start) if next_start < len(matches) else None
|
||||
)
|
||||
|
||||
async def apply_patches(self, *, tenant_id: uuid.UUID, patches: Sequence[PayloadPatch]) -> None:
|
||||
self.patch_batches.append(list(patches))
|
||||
if self.fail_next:
|
||||
self.fail_next = False
|
||||
raise RuntimeError("simulated point repository failure")
|
||||
|
||||
for patch in patches:
|
||||
point = self.points.get(str(patch.point_id))
|
||||
if point is None or point.tenant_id != tenant_id:
|
||||
continue
|
||||
if patch.expected_version is not None and point.version != patch.expected_version:
|
||||
continue
|
||||
# Re-validated rather than `model_copy`d, because a patch payload
|
||||
# carries wire values (an ISO `deleted_at`, a stringified
|
||||
# `previous_chunk_id`) exactly as it would reach Qdrant. Copying
|
||||
# without validation would leave the fake holding a `str` where a
|
||||
# read from real Qdrant returns a `datetime`, and a service bug that
|
||||
# depends on the difference would pass here and fail in production.
|
||||
updated = point.model_dump()
|
||||
updated.update(patch.payload)
|
||||
self.points[str(patch.point_id)] = Point.model_validate(updated)
|
||||
|
||||
Reference in New Issue
Block a user