test(points): cover soft delete, relinking, and the file sweep
Why: - The failure modes worth testing are races, and they are cheap to force against the fake and expensive to observe anywhere else. Changes: - Unit tests for both boundaries, the repeat delete, a missing neighbour, the traversal property after several deletes, and — via a repository that bumps a rival's version before each apply — both the partial-apply repair and the unconvergent 409. - One new shared contract scenario (a multi-point batch applies every patch, including nulling a pointer) so it runs against the fake and real Qdrant. - HTTP tests against real Postgres and Qdrant for relinking, soft-not-hard delete, cross-tenant 404, and scope enforcement on both routes. - create_source_file factory for tests addressing a file without uploading. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.application.auth.keys import generate_api_key, hash_secret
|
||||
from src.infrastructure.postgres.models.api_key import ApiKey
|
||||
from src.infrastructure.postgres.models.source_file import SourceFile
|
||||
from src.infrastructure.postgres.models.tenant import Tenant
|
||||
from src.infrastructure.postgres.models.tenant_domain import TenantDomain
|
||||
|
||||
@@ -50,6 +51,36 @@ async def create_api_key(
|
||||
return api_key, full_key
|
||||
|
||||
|
||||
async def create_source_file(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
tenant: Tenant,
|
||||
source_file_id: uuid.UUID | None = None,
|
||||
domain: str = "general",
|
||||
status: str = "active",
|
||||
) -> SourceFile:
|
||||
"""A `source_files` row for tests that address a file without uploading one.
|
||||
|
||||
`DELETE /v1/files/{file_id}` authorizes against this row before touching a
|
||||
single point, so a delete test needs it even though the interesting state
|
||||
lives in Qdrant.
|
||||
"""
|
||||
source_file = SourceFile(
|
||||
id=source_file_id or uuid.uuid4(),
|
||||
tenant_id=tenant.id,
|
||||
domain=domain,
|
||||
source_filename="handbook.docx",
|
||||
source_type="docx",
|
||||
content_sha256="0" * 64,
|
||||
byte_size=1024,
|
||||
storage_uri="s3://bucket/key",
|
||||
status=status,
|
||||
)
|
||||
session.add(source_file)
|
||||
await session.flush()
|
||||
return source_file
|
||||
|
||||
|
||||
async def create_tenant_domain(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
|
||||
@@ -329,6 +329,51 @@ async def scenario_patch_cannot_cross_tenants(
|
||||
assert victim.content == "onboarding checklist for new staff"
|
||||
|
||||
|
||||
async def scenario_patch_batch_applies_every_patch_in_one_call(
|
||||
repository: PointRepository, *, tenant_a: uuid.UUID, file_a: uuid.UUID
|
||||
) -> None:
|
||||
"""One `apply_patches` call carrying several points applies all of them.
|
||||
|
||||
This is the primitive soft delete is built on: a deactivation plus its two
|
||||
neighbour relinks go out together, and a batch that silently applied only
|
||||
its first operation would leave the pointer chain broken — the exact defect
|
||||
ADR-0002 rules out. Nulling a pointer is included because `None` and
|
||||
"absent" are different payload values, and only one of them clears a link.
|
||||
"""
|
||||
deleted, previous, following = (point_id_for(file_a, index) for index in (1, 0, 2))
|
||||
await repository.apply_patches(
|
||||
tenant_id=tenant_a,
|
||||
patches=[
|
||||
PayloadPatch(
|
||||
point_id=deleted,
|
||||
payload={"is_active": False, "version": 2},
|
||||
expected_version=1,
|
||||
),
|
||||
PayloadPatch(
|
||||
point_id=previous,
|
||||
payload={"next_chunk_id": str(following), "version": 2},
|
||||
expected_version=1,
|
||||
),
|
||||
PayloadPatch(
|
||||
point_id=following,
|
||||
payload={"previous_chunk_id": None, "version": 2},
|
||||
expected_version=1,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
applied = {
|
||||
point.point_id: point
|
||||
for point in await repository.get_many(
|
||||
tenant_id=tenant_a, point_ids=[deleted, previous, following]
|
||||
)
|
||||
}
|
||||
assert applied[deleted].is_active is False
|
||||
assert applied[previous].next_chunk_id == following
|
||||
assert applied[following].previous_chunk_id is None
|
||||
assert [applied[point_id].version for point_id in (deleted, previous, following)] == [2, 2, 2]
|
||||
|
||||
|
||||
# Scenarios that only read, safe to run in any order against one seeded corpus.
|
||||
READ_SCENARIOS = (
|
||||
scenario_get_returns_point_within_tenant,
|
||||
@@ -350,4 +395,5 @@ WRITE_SCENARIOS = (
|
||||
scenario_patch_applies_when_version_matches,
|
||||
scenario_patch_is_a_noop_when_version_is_stale,
|
||||
scenario_patch_cannot_cross_tenants,
|
||||
scenario_patch_batch_applies_every_patch_in_one_call,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user