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