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)
|
||||
|
||||
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))
|
||||
327
tests/support/point_contract.py
Normal file
327
tests/support/point_contract.py
Normal file
@@ -0,0 +1,327 @@
|
||||
"""One behavioural contract, run against both `PointRepository` implementations.
|
||||
|
||||
Plan 002 Phase 1's exit criterion is that the Qdrant adapter and the in-memory
|
||||
fake agree on filtering, ordering, and pagination. Asserting that by writing two
|
||||
parallel test files invites them to drift — the usual outcome is a fake that
|
||||
quietly grows more forgiving than the real store, so unit tests keep passing
|
||||
while production does something else.
|
||||
|
||||
So the scenarios live here once. `tests/unit/application/points/` runs them
|
||||
against `FakePointRepository`; `tests/integration/qdrant/` runs the identical
|
||||
functions against real Qdrant. A divergence fails one of the two runs rather
|
||||
than hiding.
|
||||
|
||||
Each scenario takes an already-seeded repository plus the ids it was seeded
|
||||
with, so seeding (which genuinely differs — one writes a dict, the other upserts
|
||||
vectors) stays outside the shared code.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from src.application.ingestion.chunking import chunk_id_for
|
||||
from src.application.points.point import Point
|
||||
from src.application.ports.point_repository import PayloadPatch, PointRepository
|
||||
|
||||
# Two tenants and two files, so every scenario can assert both that the right
|
||||
# rows come back and that the wrong ones do not.
|
||||
SEEDED_AT = datetime(2026, 1, 1, 12, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SeedSpec:
|
||||
"""One point to seed. Deliberately flat — the fixture builds the model."""
|
||||
|
||||
tenant_id: uuid.UUID
|
||||
file_id: uuid.UUID
|
||||
chunk_index: int
|
||||
content: str
|
||||
order_id: float
|
||||
is_active: bool = True
|
||||
version: int = 1
|
||||
domain: str = "support"
|
||||
|
||||
|
||||
def point_id_for(file_id: uuid.UUID, chunk_index: int) -> uuid.UUID:
|
||||
"""The deterministic point id (ADR-0001), re-exported for readability."""
|
||||
return chunk_id_for(file_id, chunk_index)
|
||||
|
||||
|
||||
def build_point(spec: SeedSpec) -> Point:
|
||||
point_id = point_id_for(spec.file_id, spec.chunk_index)
|
||||
return Point(
|
||||
point_id=point_id,
|
||||
tenant_id=spec.tenant_id,
|
||||
domain=spec.domain,
|
||||
file_id=spec.file_id,
|
||||
chunk_id=point_id,
|
||||
content=spec.content,
|
||||
content_type="paragraph",
|
||||
source_filename="handbook.docx",
|
||||
source_type="docx",
|
||||
order_id=spec.order_id,
|
||||
chunk_index=spec.chunk_index,
|
||||
previous_chunk_id=None,
|
||||
next_chunk_id=None,
|
||||
is_active=spec.is_active,
|
||||
deleted_at=None if spec.is_active else SEEDED_AT,
|
||||
created_at=SEEDED_AT,
|
||||
updated_at=SEEDED_AT,
|
||||
created_by="ingestion",
|
||||
updated_by="ingestion",
|
||||
version=spec.version,
|
||||
content_hash="0" * 64,
|
||||
embedding_model_version="test-model",
|
||||
)
|
||||
|
||||
|
||||
def seed_specs(
|
||||
tenant_a: uuid.UUID,
|
||||
tenant_b: uuid.UUID,
|
||||
file_a: uuid.UUID,
|
||||
file_b: uuid.UUID,
|
||||
file_c: uuid.UUID,
|
||||
) -> tuple[SeedSpec, ...]:
|
||||
"""The fixed corpus every scenario below assumes.
|
||||
|
||||
Ordering is deliberately not insertion order: `order_id` 3.0 is seeded
|
||||
before 2.0 so a repository that returns rows in write order fails
|
||||
`scenario_list_orders_by_order_id` instead of passing by accident.
|
||||
|
||||
Tenant B owns its own file. Sharing a `file_id` across tenants would be a
|
||||
fixture that cannot occur: point ids are derived from `file_id` plus
|
||||
`chunk_index` alone, so two tenants in one file would collide on a single
|
||||
id and the corpus would be testing an impossible state.
|
||||
"""
|
||||
return (
|
||||
SeedSpec(tenant_a, file_a, 0, "onboarding checklist for new staff", 1.0),
|
||||
SeedSpec(tenant_a, file_a, 2, "payroll schedule and bank details", 3.0),
|
||||
SeedSpec(tenant_a, file_a, 1, "expense policy and receipts", 2.0),
|
||||
SeedSpec(tenant_a, file_a, 3, "retired parking policy", 4.0, is_active=False),
|
||||
SeedSpec(tenant_a, file_b, 0, "supplier onboarding contacts", 1.0, domain="ops"),
|
||||
SeedSpec(tenant_b, file_c, 0, "other tenant onboarding secrets", 1.0),
|
||||
)
|
||||
|
||||
|
||||
async def scenario_get_returns_point_within_tenant(
|
||||
repository: PointRepository, *, tenant_a: uuid.UUID, file_a: uuid.UUID
|
||||
) -> None:
|
||||
point = await repository.get(tenant_id=tenant_a, point_id=point_id_for(file_a, 0))
|
||||
assert point is not None
|
||||
assert point.content == "onboarding checklist for new staff"
|
||||
assert point.chunk_index == 0
|
||||
assert point.order_id == 1.0
|
||||
|
||||
|
||||
async def scenario_get_hides_other_tenants_point(
|
||||
repository: PointRepository, *, tenant_b: uuid.UUID, file_a: uuid.UUID
|
||||
) -> None:
|
||||
"""Tenant B probing a real point id that belongs to tenant A gets nothing.
|
||||
|
||||
The point exists, so this distinguishes a tenant filter from a plain
|
||||
existence check — the difference between `404` and a leak.
|
||||
"""
|
||||
assert await repository.get(tenant_id=tenant_b, point_id=point_id_for(file_a, 0)) is None
|
||||
|
||||
|
||||
async def scenario_get_reaches_inactive_points(
|
||||
repository: PointRepository, *, tenant_a: uuid.UUID, file_a: uuid.UUID
|
||||
) -> None:
|
||||
"""A direct `get` is not subject to the implied `is_active` read filter.
|
||||
|
||||
Soft delete and relinking both address points by id and must be able to see
|
||||
a deactivated one; only listings hide them.
|
||||
"""
|
||||
point = await repository.get(tenant_id=tenant_a, point_id=point_id_for(file_a, 3))
|
||||
assert point is not None
|
||||
assert point.is_active is False
|
||||
|
||||
|
||||
async def scenario_get_many_returns_only_this_tenants_points(
|
||||
repository: PointRepository, *, tenant_a: uuid.UUID, file_a: uuid.UUID
|
||||
) -> None:
|
||||
found = await repository.get_many(
|
||||
tenant_id=tenant_a,
|
||||
point_ids=[point_id_for(file_a, 0), point_id_for(file_a, 1), uuid.uuid4()],
|
||||
)
|
||||
assert {point.chunk_index for point in found} == {0, 1}
|
||||
|
||||
|
||||
async def scenario_list_orders_by_order_id(
|
||||
repository: PointRepository, *, tenant_a: uuid.UUID, file_a: uuid.UUID
|
||||
) -> None:
|
||||
page = await repository.list_by_file(tenant_id=tenant_a, file_id=file_a, limit=10)
|
||||
assert [point.order_id for point in page.points] == [1.0, 2.0, 3.0]
|
||||
assert page.next_cursor is None
|
||||
|
||||
|
||||
async def scenario_list_excludes_inactive_by_default(
|
||||
repository: PointRepository, *, tenant_a: uuid.UUID, file_a: uuid.UUID
|
||||
) -> None:
|
||||
default = await repository.list_by_file(tenant_id=tenant_a, file_id=file_a, limit=10)
|
||||
assert 4.0 not in [point.order_id for point in default.points]
|
||||
|
||||
opted_in = await repository.list_by_file(
|
||||
tenant_id=tenant_a, file_id=file_a, limit=10, include_inactive=True
|
||||
)
|
||||
assert [point.order_id for point in opted_in.points] == [1.0, 2.0, 3.0, 4.0]
|
||||
|
||||
|
||||
async def scenario_list_paginates_without_skipping_or_repeating(
|
||||
repository: PointRepository, *, tenant_a: uuid.UUID, file_a: uuid.UUID
|
||||
) -> None:
|
||||
first = await repository.list_by_file(tenant_id=tenant_a, file_id=file_a, limit=2)
|
||||
assert [point.order_id for point in first.points] == [1.0, 2.0]
|
||||
assert first.next_cursor is not None
|
||||
|
||||
second = await repository.list_by_file(
|
||||
tenant_id=tenant_a, file_id=file_a, limit=2, cursor=first.next_cursor
|
||||
)
|
||||
assert [point.order_id for point in second.points] == [3.0]
|
||||
assert second.next_cursor is None
|
||||
|
||||
|
||||
async def scenario_list_is_scoped_to_one_file(
|
||||
repository: PointRepository, *, tenant_a: uuid.UUID, file_b: uuid.UUID
|
||||
) -> None:
|
||||
page = await repository.list_by_file(tenant_id=tenant_a, file_id=file_b, limit=10)
|
||||
assert [point.content for point in page.points] == ["supplier onboarding contacts"]
|
||||
|
||||
|
||||
async def scenario_count_respects_tenant_and_filters(
|
||||
repository: PointRepository,
|
||||
*,
|
||||
tenant_a: uuid.UUID,
|
||||
tenant_b: uuid.UUID,
|
||||
file_a: uuid.UUID,
|
||||
) -> None:
|
||||
assert await repository.count(tenant_id=tenant_a) == 4
|
||||
assert await repository.count(tenant_id=tenant_a, include_inactive=True) == 5
|
||||
assert await repository.count(tenant_id=tenant_a, file_id=file_a) == 3
|
||||
assert await repository.count(tenant_id=tenant_a, domain="ops") == 1
|
||||
assert await repository.count(tenant_id=tenant_b) == 1
|
||||
|
||||
|
||||
async def scenario_keyword_search_matches_content_within_tenant(
|
||||
repository: PointRepository, *, tenant_a: uuid.UUID, tenant_b: uuid.UUID
|
||||
) -> None:
|
||||
found = await repository.keyword_search(tenant_id=tenant_a, query="onboarding", limit=10)
|
||||
assert {point.content for point in found.points} == {
|
||||
"onboarding checklist for new staff",
|
||||
"supplier onboarding contacts",
|
||||
}
|
||||
|
||||
# The same term matches a different tenant's point, which must not leak.
|
||||
other = await repository.keyword_search(tenant_id=tenant_b, query="onboarding", limit=10)
|
||||
assert {point.content for point in other.points} == {"other tenant onboarding secrets"}
|
||||
|
||||
|
||||
async def scenario_keyword_search_excludes_inactive_by_default(
|
||||
repository: PointRepository, *, tenant_a: uuid.UUID
|
||||
) -> None:
|
||||
""" "retired parking policy" is seeded inactive, so only the opt-in sees it."""
|
||||
default = await repository.keyword_search(tenant_id=tenant_a, query="parking", limit=10)
|
||||
assert default.points == ()
|
||||
|
||||
opted_in = await repository.keyword_search(
|
||||
tenant_id=tenant_a, query="parking", limit=10, include_inactive=True
|
||||
)
|
||||
assert {point.content for point in opted_in.points} == {"retired parking policy"}
|
||||
|
||||
|
||||
async def scenario_keyword_search_filters_by_domain(
|
||||
repository: PointRepository, *, tenant_a: uuid.UUID
|
||||
) -> None:
|
||||
found = await repository.keyword_search(
|
||||
tenant_id=tenant_a, query="onboarding", limit=10, domain="ops"
|
||||
)
|
||||
assert {point.content for point in found.points} == {"supplier onboarding contacts"}
|
||||
|
||||
|
||||
async def scenario_patch_applies_when_version_matches(
|
||||
repository: PointRepository, *, tenant_a: uuid.UUID, file_a: uuid.UUID
|
||||
) -> None:
|
||||
target = point_id_for(file_a, 0)
|
||||
await repository.apply_patches(
|
||||
tenant_id=tenant_a,
|
||||
patches=[
|
||||
PayloadPatch(
|
||||
point_id=target,
|
||||
payload={"content": "amended onboarding checklist", "version": 2},
|
||||
expected_version=1,
|
||||
)
|
||||
],
|
||||
)
|
||||
point = await repository.get(tenant_id=tenant_a, point_id=target)
|
||||
assert point is not None
|
||||
assert point.content == "amended onboarding checklist"
|
||||
assert point.version == 2
|
||||
|
||||
|
||||
async def scenario_patch_is_a_noop_when_version_is_stale(
|
||||
repository: PointRepository, *, tenant_a: uuid.UUID, file_a: uuid.UUID
|
||||
) -> None:
|
||||
"""A stale guard must match nothing rather than raise or clobber.
|
||||
|
||||
This is the behaviour the service's post-apply check depends on: Qdrant
|
||||
reports success for a filtered `set_payload` that matched zero points, so
|
||||
"did my write land?" is only answerable by reading back.
|
||||
"""
|
||||
target = point_id_for(file_a, 1)
|
||||
await repository.apply_patches(
|
||||
tenant_id=tenant_a,
|
||||
patches=[
|
||||
PayloadPatch(
|
||||
point_id=target,
|
||||
payload={"content": "should not land"},
|
||||
expected_version=99,
|
||||
)
|
||||
],
|
||||
)
|
||||
point = await repository.get(tenant_id=tenant_a, point_id=target)
|
||||
assert point is not None
|
||||
assert point.content == "expense policy and receipts"
|
||||
|
||||
|
||||
async def scenario_patch_cannot_cross_tenants(
|
||||
repository: PointRepository, *, tenant_a: uuid.UUID, tenant_b: uuid.UUID, file_a: uuid.UUID
|
||||
) -> None:
|
||||
"""Tenant B patching tenant A's point id must change nothing.
|
||||
|
||||
An unguarded patch (no `expected_version`) addressed by a known-good id is
|
||||
the sharpest form of the probe: only the tenant condition can stop it, so a
|
||||
repository that filtered on id alone would rewrite another tenant's row.
|
||||
"""
|
||||
target = point_id_for(file_a, 0)
|
||||
await repository.apply_patches(
|
||||
tenant_id=tenant_b,
|
||||
patches=[PayloadPatch(point_id=target, payload={"content": "hijacked"})],
|
||||
)
|
||||
victim = await repository.get(tenant_id=tenant_a, point_id=target)
|
||||
assert victim is not None
|
||||
assert victim.content == "onboarding checklist for new staff"
|
||||
|
||||
|
||||
# Scenarios that only read, safe to run in any order against one seeded corpus.
|
||||
READ_SCENARIOS = (
|
||||
scenario_get_returns_point_within_tenant,
|
||||
scenario_get_hides_other_tenants_point,
|
||||
scenario_get_reaches_inactive_points,
|
||||
scenario_get_many_returns_only_this_tenants_points,
|
||||
scenario_list_orders_by_order_id,
|
||||
scenario_list_excludes_inactive_by_default,
|
||||
scenario_list_paginates_without_skipping_or_repeating,
|
||||
scenario_list_is_scoped_to_one_file,
|
||||
scenario_count_respects_tenant_and_filters,
|
||||
scenario_keyword_search_matches_content_within_tenant,
|
||||
scenario_keyword_search_excludes_inactive_by_default,
|
||||
scenario_keyword_search_filters_by_domain,
|
||||
)
|
||||
|
||||
# Scenarios that mutate, and therefore need a freshly seeded corpus each.
|
||||
WRITE_SCENARIOS = (
|
||||
scenario_patch_applies_when_version_matches,
|
||||
scenario_patch_is_a_noop_when_version_is_stale,
|
||||
scenario_patch_cannot_cross_tenants,
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""`FakePointRepository` against the shared `PointRepository` contract.
|
||||
|
||||
The same scenarios run against real Qdrant in
|
||||
`tests/integration/qdrant/test_point_repository.py`. Keeping both runs green is
|
||||
what stops the fake from drifting into something more permissive than the store
|
||||
it stands in for — see `tests/support/point_contract.py`.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
import pytest
|
||||
|
||||
from src.application.ports.point_repository import PointRepository
|
||||
from tests.fakes import FakePointRepository
|
||||
from tests.support import point_contract
|
||||
from tests.support.point_contract import build_point, seed_specs
|
||||
|
||||
pytestmark = [pytest.mark.unit, pytest.mark.asyncio]
|
||||
|
||||
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 _seeded() -> FakePointRepository:
|
||||
repository = FakePointRepository()
|
||||
for spec in seed_specs(TENANT_A, TENANT_B, FILE_A, FILE_B, FILE_C):
|
||||
repository.add(build_point(spec))
|
||||
return repository
|
||||
|
||||
|
||||
def _arguments(scenario: Scenario) -> dict[str, uuid.UUID]:
|
||||
"""Pass only the ids a scenario declares, so each one names its own inputs."""
|
||||
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_fake_point_repository_satisfies_the_contract(scenario: Scenario) -> None:
|
||||
repository: PointRepository = _seeded()
|
||||
await scenario(repository, **_arguments(scenario))
|
||||
Reference in New Issue
Block a user