Why: - The helper turning a SeedSpec into an upsertable ChunkPoint lived inside one integration test. A second suite now needs to seed real Qdrant the same way, and a copy would let the two drift. Changes: - Move `_chunk_point` into tests/support/point_contract.py as `chunk_point_for`, beside the `build_point` read model it derives its payload from. Impact: - Pure move. No behaviour change; enables the API suite that follows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
354 lines
14 KiB
Python
354 lines
14 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.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, PointRepository
|
|
from src.infrastructure.qdrant.collection import (
|
|
DENSE_NOMIC_DIMENSIONS,
|
|
DENSE_OPENAI_DIMENSIONS,
|
|
)
|
|
|
|
# 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 chunk_point_for(spec: SeedSpec) -> ChunkPoint:
|
|
"""A `SeedSpec` as something upsertable into real Qdrant.
|
|
|
|
The payload is taken straight off `build_point`'s read model rather than
|
|
hand-written, so the write shape and the read shape cannot drift apart. The
|
|
vectors are constant filler: nothing in plan 002's read paths scores by
|
|
similarity, so their values are irrelevant and their dimensions are not.
|
|
"""
|
|
point = build_point(spec)
|
|
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=point.model_dump(mode="json", exclude={"point_id", "vectors"}),
|
|
)
|
|
|
|
|
|
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,
|
|
)
|