Files
chatbot_v3/tests/support/point_contract.py
Ali Zarinkolah 923ac8e5d6 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>
2026-08-22 13:09:50 +03:30

328 lines
12 KiB
Python

"""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,
)