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