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:
276
tests/integration/qdrant/test_points_deletion_api.py
Normal file
276
tests/integration/qdrant/test_points_deletion_api.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""Soft delete over HTTP, against real Postgres and real Qdrant (ADR-0002).
|
||||
|
||||
The unit tests in `tests/unit/application/points/test_deletion.py` decide
|
||||
whether the relink logic is right. This file decides whether it is right
|
||||
*through the stack*: real routing and scope checks, a tenant derived from a real
|
||||
API key, and a real filtered `set_payload` batch — the last of which is the part
|
||||
a fake can only approximate, since Qdrant reports success for a patch that
|
||||
matched nothing.
|
||||
|
||||
`DELETE /v1/files/{file_id}` is here too rather than with the upload tests: it
|
||||
spans both stores, and the assertion that matters is the one about points.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from asgi_lifespan import LifespanManager
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from src.bootstrap.dependencies import get_sessionmaker
|
||||
from src.config import QdrantSettings, Settings
|
||||
from src.infrastructure.postgres.repositories import source_files as source_files_repo
|
||||
from src.infrastructure.qdrant.collection import ensure_chunks_collection
|
||||
from src.infrastructure.qdrant.points import QdrantPointStorage
|
||||
from src.main import create_app
|
||||
from tests.support.factories import create_api_key, create_source_file, create_tenant
|
||||
from tests.support.point_contract import SeedSpec, chunk_point_for, point_id_for
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.integration,
|
||||
pytest.mark.qdrant,
|
||||
pytest.mark.postgres,
|
||||
pytest.mark.asyncio(loop_scope="session"),
|
||||
]
|
||||
|
||||
FILE_ID = uuid.UUID("cccccccc-cccc-4ccc-8ccc-cccccccccccc")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_settings(settings: Settings, qdrant_settings: QdrantSettings) -> Settings:
|
||||
return settings.model_copy(update={"qdrant": qdrant_settings})
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(loop_scope="session")
|
||||
async def api_client(
|
||||
api_settings: Settings, db_sessionmaker: async_sessionmaker[AsyncSession]
|
||||
) -> AsyncIterator[AsyncClient]:
|
||||
app = create_app(api_settings)
|
||||
app.dependency_overrides[get_sessionmaker] = lambda: db_sessionmaker
|
||||
async with (
|
||||
LifespanManager(app) as manager,
|
||||
AsyncClient(transport=ASGITransport(app=manager.app), base_url="http://test") as client,
|
||||
):
|
||||
yield client
|
||||
|
||||
|
||||
def _auth(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
async def _tenant_with_key(
|
||||
db_session: AsyncSession, *, scopes: list[str] | None = None
|
||||
) -> tuple[uuid.UUID, str]:
|
||||
tenant = await create_tenant(db_session)
|
||||
_, token = await create_api_key(
|
||||
db_session,
|
||||
tenant=tenant,
|
||||
scopes=scopes if scopes is not None else ["points:read", "points:write"],
|
||||
)
|
||||
await db_session.commit()
|
||||
return tenant.id, token
|
||||
|
||||
|
||||
async def _seed_chain(
|
||||
qdrant_client: AsyncQdrantClient,
|
||||
qdrant_settings: QdrantSettings,
|
||||
*,
|
||||
tenant_id: uuid.UUID,
|
||||
file_id: uuid.UUID = FILE_ID,
|
||||
length: int = 3,
|
||||
) -> list[uuid.UUID]:
|
||||
"""A linked run of points, written the way ingestion writes them."""
|
||||
await ensure_chunks_collection(qdrant_client, collection=qdrant_settings.collection)
|
||||
ids = [point_id_for(file_id, index) for index in range(length)]
|
||||
points = []
|
||||
for index in range(length):
|
||||
chunk_point = chunk_point_for(
|
||||
SeedSpec(tenant_id, file_id, index, f"chunk {index}", float(index + 1))
|
||||
)
|
||||
chunk_point.payload["previous_chunk_id"] = str(ids[index - 1]) if index else None
|
||||
chunk_point.payload["next_chunk_id"] = str(ids[index + 1]) if index + 1 < length else None
|
||||
points.append(chunk_point)
|
||||
|
||||
storage = QdrantPointStorage(qdrant_client, collection=qdrant_settings.collection)
|
||||
await storage.upsert_points(points)
|
||||
return ids
|
||||
|
||||
|
||||
async def _read_point(
|
||||
api_client: AsyncClient, token: str, point_id: uuid.UUID
|
||||
) -> dict[str, object]:
|
||||
response = await api_client.get(f"/v1/points/{point_id}", headers=_auth(token))
|
||||
assert response.status_code == 200
|
||||
payload: dict[str, object] = response.json()
|
||||
return payload
|
||||
|
||||
|
||||
async def test_delete_point_deactivates_it_and_relinks_its_neighbours(
|
||||
api_client: AsyncClient,
|
||||
db_session: AsyncSession,
|
||||
qdrant_client: AsyncQdrantClient,
|
||||
qdrant_settings: QdrantSettings,
|
||||
) -> None:
|
||||
tenant_id, token = await _tenant_with_key(db_session)
|
||||
first, middle, last = await _seed_chain(qdrant_client, qdrant_settings, tenant_id=tenant_id)
|
||||
|
||||
response = await api_client.delete(f"/v1/points/{middle}", headers=_auth(token))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["is_active"] is False
|
||||
assert response.json()["deleted_at"] is not None
|
||||
assert (await _read_point(api_client, token, first))["next_chunk_id"] == str(last)
|
||||
assert (await _read_point(api_client, token, last))["previous_chunk_id"] == str(first)
|
||||
|
||||
|
||||
async def test_delete_point_keeps_the_point_in_qdrant(
|
||||
api_client: AsyncClient,
|
||||
db_session: AsyncSession,
|
||||
qdrant_client: AsyncQdrantClient,
|
||||
qdrant_settings: QdrantSettings,
|
||||
) -> None:
|
||||
"""Soft delete means soft: the point is still there, just not listed."""
|
||||
tenant_id, token = await _tenant_with_key(db_session)
|
||||
_, middle, _ = await _seed_chain(qdrant_client, qdrant_settings, tenant_id=tenant_id)
|
||||
|
||||
await api_client.delete(f"/v1/points/{middle}", headers=_auth(token))
|
||||
|
||||
listed = await api_client.get(
|
||||
"/v1/points", params={"file_id": str(FILE_ID)}, headers=_auth(token)
|
||||
)
|
||||
assert str(middle) not in [point["point_id"] for point in listed.json()["points"]]
|
||||
assert (await _read_point(api_client, token, middle))["is_active"] is False
|
||||
|
||||
|
||||
async def test_delete_point_is_a_noop_success_the_second_time(
|
||||
api_client: AsyncClient,
|
||||
db_session: AsyncSession,
|
||||
qdrant_client: AsyncQdrantClient,
|
||||
qdrant_settings: QdrantSettings,
|
||||
) -> None:
|
||||
tenant_id, token = await _tenant_with_key(db_session)
|
||||
first, middle, _ = await _seed_chain(qdrant_client, qdrant_settings, tenant_id=tenant_id)
|
||||
await api_client.delete(f"/v1/points/{middle}", headers=_auth(token))
|
||||
version_after_first = (await _read_point(api_client, token, first))["version"]
|
||||
|
||||
again = await api_client.delete(f"/v1/points/{middle}", headers=_auth(token))
|
||||
|
||||
assert again.status_code == 200
|
||||
assert again.json()["is_active"] is False
|
||||
assert (await _read_point(api_client, token, first))["version"] == version_after_first
|
||||
|
||||
|
||||
async def test_delete_point_returns_404_for_another_tenants_point(
|
||||
api_client: AsyncClient,
|
||||
db_session: AsyncSession,
|
||||
qdrant_client: AsyncQdrantClient,
|
||||
qdrant_settings: QdrantSettings,
|
||||
) -> None:
|
||||
"""`404`, not `403`, and nothing is deactivated on the way to saying so."""
|
||||
owner_id, owner_token = await _tenant_with_key(db_session)
|
||||
_, intruder_token = await _tenant_with_key(db_session)
|
||||
_, middle, _ = await _seed_chain(qdrant_client, qdrant_settings, tenant_id=owner_id)
|
||||
|
||||
response = await api_client.delete(f"/v1/points/{middle}", headers=_auth(intruder_token))
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["error"]["code"] == "not_found"
|
||||
assert (await _read_point(api_client, owner_token, middle))["is_active"] is True
|
||||
|
||||
|
||||
async def test_delete_point_requires_the_points_write_scope(
|
||||
api_client: AsyncClient,
|
||||
db_session: AsyncSession,
|
||||
qdrant_client: AsyncQdrantClient,
|
||||
qdrant_settings: QdrantSettings,
|
||||
) -> None:
|
||||
"""A read-only key can see a point but must not be able to remove it."""
|
||||
tenant_id, token = await _tenant_with_key(db_session, scopes=["points:read"])
|
||||
_, middle, _ = await _seed_chain(qdrant_client, qdrant_settings, tenant_id=tenant_id)
|
||||
|
||||
response = await api_client.delete(f"/v1/points/{middle}", headers=_auth(token))
|
||||
|
||||
assert response.status_code == 403
|
||||
assert (await _read_point(api_client, token, middle))["is_active"] is True
|
||||
|
||||
|
||||
async def test_delete_file_deactivates_every_point_and_retires_the_row(
|
||||
api_client: AsyncClient,
|
||||
db_session: AsyncSession,
|
||||
db_sessionmaker: async_sessionmaker[AsyncSession],
|
||||
qdrant_client: AsyncQdrantClient,
|
||||
qdrant_settings: QdrantSettings,
|
||||
) -> None:
|
||||
tenant = await create_tenant(db_session)
|
||||
_, token = await create_api_key(
|
||||
db_session, tenant=tenant, scopes=["points:read", "points:write"]
|
||||
)
|
||||
file_id = uuid.uuid4()
|
||||
await create_source_file(db_session, tenant=tenant, source_file_id=file_id)
|
||||
await db_session.commit()
|
||||
await _seed_chain(qdrant_client, qdrant_settings, tenant_id=tenant.id, file_id=file_id)
|
||||
|
||||
response = await api_client.delete(f"/v1/files/{file_id}", headers=_auth(token))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["points_soft_deleted"] == 3
|
||||
listed = await api_client.get(
|
||||
"/v1/points", params={"file_id": str(file_id)}, headers=_auth(token)
|
||||
)
|
||||
assert listed.json()["points"] == []
|
||||
|
||||
async with db_sessionmaker() as session:
|
||||
row = await source_files_repo.get_by_id(
|
||||
session, tenant_id=tenant.id, source_file_id=file_id
|
||||
)
|
||||
assert row is not None
|
||||
assert row.status == "soft_deleted"
|
||||
assert row.deleted_at is not None
|
||||
|
||||
|
||||
async def test_delete_file_returns_404_for_another_tenants_file(
|
||||
api_client: AsyncClient,
|
||||
db_session: AsyncSession,
|
||||
qdrant_client: AsyncQdrantClient,
|
||||
qdrant_settings: QdrantSettings,
|
||||
) -> None:
|
||||
"""The authorization check runs before the sweep, so nothing is deactivated."""
|
||||
owner = await create_tenant(db_session)
|
||||
_, owner_token = await create_api_key(
|
||||
db_session, tenant=owner, scopes=["points:read", "points:write"]
|
||||
)
|
||||
intruder = await create_tenant(db_session)
|
||||
_, intruder_token = await create_api_key(
|
||||
db_session, tenant=intruder, scopes=["points:read", "points:write"]
|
||||
)
|
||||
file_id = uuid.uuid4()
|
||||
await create_source_file(db_session, tenant=owner, source_file_id=file_id)
|
||||
await db_session.commit()
|
||||
await _seed_chain(qdrant_client, qdrant_settings, tenant_id=owner.id, file_id=file_id)
|
||||
|
||||
response = await api_client.delete(f"/v1/files/{file_id}", headers=_auth(intruder_token))
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["error"]["code"] == "not_found"
|
||||
listed = await api_client.get(
|
||||
"/v1/points", params={"file_id": str(file_id)}, headers=_auth(owner_token)
|
||||
)
|
||||
assert len(listed.json()["points"]) == 3
|
||||
|
||||
|
||||
async def test_delete_file_requires_the_points_write_scope(
|
||||
api_client: AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
tenant = await create_tenant(db_session)
|
||||
_, token = await create_api_key(db_session, tenant=tenant, scopes=["files:write"])
|
||||
file_id = uuid.uuid4()
|
||||
await create_source_file(db_session, tenant=tenant, source_file_id=file_id)
|
||||
await db_session.commit()
|
||||
|
||||
response = await api_client.delete(f"/v1/files/{file_id}", headers=_auth(token))
|
||||
|
||||
assert response.status_code == 403
|
||||
Reference in New Issue
Block a user