7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
486 lines
15 KiB
Python
486 lines
15 KiB
Python
"""
|
|
Tests for collection deletion functionality.
|
|
|
|
Tests the CollectionDeletionService methods:
|
|
- delete_collection
|
|
- delete_collection_index_only
|
|
"""
|
|
|
|
import pytest
|
|
import uuid
|
|
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from local_deep_research.database.models.library import (
|
|
Document,
|
|
DocumentChunk,
|
|
DocumentCollection,
|
|
Collection,
|
|
CollectionFolder,
|
|
SourceType,
|
|
RAGIndex,
|
|
EmbeddingProvider,
|
|
)
|
|
from local_deep_research.database.models.base import Base
|
|
|
|
|
|
@pytest.fixture
|
|
def test_engine():
|
|
"""Create an in-memory SQLite database for testing."""
|
|
engine = create_engine("sqlite:///:memory:")
|
|
Base.metadata.create_all(engine)
|
|
yield engine
|
|
engine.dispose()
|
|
|
|
|
|
@pytest.fixture
|
|
def test_session(test_engine):
|
|
"""Create a session for testing."""
|
|
Session = sessionmaker(bind=test_engine)
|
|
session = Session()
|
|
yield session
|
|
session.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def source_type(test_session):
|
|
"""Create a source type for testing."""
|
|
st = SourceType(
|
|
id=str(uuid.uuid4()),
|
|
name="user_upload",
|
|
display_name="User Upload",
|
|
)
|
|
test_session.add(st)
|
|
test_session.commit()
|
|
return st
|
|
|
|
|
|
@pytest.fixture
|
|
def test_collection(test_session):
|
|
"""Create a test collection with embedding settings."""
|
|
collection = Collection(
|
|
id=str(uuid.uuid4()),
|
|
name="Test Collection",
|
|
description="A test collection",
|
|
embedding_model="all-MiniLM-L6-v2",
|
|
embedding_model_type=EmbeddingProvider.SENTENCE_TRANSFORMERS,
|
|
embedding_dimension=384,
|
|
chunk_size=500,
|
|
chunk_overlap=50,
|
|
)
|
|
test_session.add(collection)
|
|
test_session.commit()
|
|
return collection
|
|
|
|
|
|
@pytest.fixture
|
|
def test_documents(test_session, source_type, test_collection):
|
|
"""Create multiple test documents in a collection."""
|
|
docs = []
|
|
for i in range(3):
|
|
doc_id = str(uuid.uuid4())
|
|
doc = Document(
|
|
id=doc_id,
|
|
source_type_id=source_type.id,
|
|
document_hash=f"hash_{doc_id[:8]}",
|
|
file_size=1024 * (i + 1),
|
|
file_type="pdf",
|
|
title=f"Test Document {i + 1}",
|
|
filename=f"test{i + 1}.pdf",
|
|
text_content=f"Content of document {i + 1}",
|
|
)
|
|
test_session.add(doc)
|
|
test_session.flush()
|
|
|
|
# Add to collection
|
|
doc_collection = DocumentCollection(
|
|
document_id=doc_id,
|
|
collection_id=test_collection.id,
|
|
indexed=True,
|
|
chunk_count=5,
|
|
)
|
|
test_session.add(doc_collection)
|
|
docs.append(doc)
|
|
|
|
test_session.commit()
|
|
return docs
|
|
|
|
|
|
@pytest.fixture
|
|
def test_chunks(test_session, test_collection, test_documents):
|
|
"""Create chunks for the test documents."""
|
|
collection_name = f"collection_{test_collection.id}"
|
|
chunks = []
|
|
|
|
for doc in test_documents:
|
|
for i in range(2):
|
|
chunk = DocumentChunk(
|
|
chunk_hash=f"chunk_{doc.id[:8]}_{i}_{uuid.uuid4().hex[:8]}",
|
|
source_type="document",
|
|
source_id=doc.id,
|
|
collection_name=collection_name,
|
|
chunk_text=f"Chunk {i} of {doc.title}",
|
|
chunk_index=i,
|
|
start_char=i * 100,
|
|
end_char=(i + 1) * 100,
|
|
word_count=10,
|
|
embedding_id=str(uuid.uuid4()),
|
|
embedding_model="all-MiniLM-L6-v2",
|
|
embedding_model_type=EmbeddingProvider.SENTENCE_TRANSFORMERS,
|
|
)
|
|
test_session.add(chunk)
|
|
chunks.append(chunk)
|
|
|
|
test_session.commit()
|
|
return chunks
|
|
|
|
|
|
@pytest.fixture
|
|
def test_rag_index(test_session, test_collection):
|
|
"""Create a RAG index for the collection."""
|
|
collection_name = f"collection_{test_collection.id}"
|
|
rag_index = RAGIndex(
|
|
collection_name=collection_name,
|
|
embedding_model="all-MiniLM-L6-v2",
|
|
embedding_model_type=EmbeddingProvider.SENTENCE_TRANSFORMERS,
|
|
embedding_dimension=384,
|
|
index_path=f"/tmp/test_index_{test_collection.id}",
|
|
index_hash=f"hash_{test_collection.id[:8]}",
|
|
chunk_size=500,
|
|
chunk_overlap=50,
|
|
chunk_count=6,
|
|
total_documents=3,
|
|
)
|
|
test_session.add(rag_index)
|
|
test_session.commit()
|
|
return rag_index
|
|
|
|
|
|
class TestCollectionDeletion:
|
|
"""Unit tests for collection deletion."""
|
|
|
|
def test_delete_collection_preserves_documents(
|
|
self, test_session, test_collection, test_documents
|
|
):
|
|
"""Documents remain after collection deletion."""
|
|
collection_id = test_collection.id
|
|
doc_ids = [d.id for d in test_documents]
|
|
|
|
# Delete collection
|
|
collection = test_session.query(Collection).get(collection_id)
|
|
test_session.delete(collection)
|
|
test_session.commit()
|
|
|
|
# Verify documents still exist
|
|
for doc_id in doc_ids:
|
|
doc = test_session.query(Document).get(doc_id)
|
|
assert doc is not None, f"Document {doc_id[:8]} should still exist"
|
|
|
|
def test_delete_collection_removes_links(
|
|
self, test_session, test_collection, test_documents
|
|
):
|
|
"""DocumentCollection links are deleted with collection."""
|
|
collection_id = test_collection.id
|
|
|
|
# Verify links exist
|
|
links_before = (
|
|
test_session.query(DocumentCollection)
|
|
.filter_by(collection_id=collection_id)
|
|
.count()
|
|
)
|
|
assert links_before == 3, "Should have 3 document links"
|
|
|
|
# Delete collection
|
|
collection = test_session.query(Collection).get(collection_id)
|
|
test_session.delete(collection)
|
|
test_session.commit()
|
|
|
|
# Verify links are deleted (CASCADE)
|
|
links_after = (
|
|
test_session.query(DocumentCollection)
|
|
.filter_by(collection_id=collection_id)
|
|
.count()
|
|
)
|
|
assert links_after == 0, "Document links should be deleted"
|
|
|
|
def test_delete_collection_removes_chunks(
|
|
self, test_session, test_collection, test_documents, test_chunks
|
|
):
|
|
"""DocumentChunks for collection are deleted."""
|
|
collection_id = test_collection.id
|
|
collection_name = f"collection_{collection_id}"
|
|
|
|
# Verify chunks exist
|
|
chunks_before = (
|
|
test_session.query(DocumentChunk)
|
|
.filter_by(collection_name=collection_name)
|
|
.count()
|
|
)
|
|
assert chunks_before == 6, "Should have 6 chunks (2 per document)"
|
|
|
|
# Manually delete chunks (no FK, must be explicit)
|
|
deleted = (
|
|
test_session.query(DocumentChunk)
|
|
.filter_by(collection_name=collection_name)
|
|
.delete(synchronize_session=False)
|
|
)
|
|
assert deleted == 6
|
|
|
|
# Delete collection
|
|
collection = test_session.query(Collection).get(collection_id)
|
|
test_session.delete(collection)
|
|
test_session.commit()
|
|
|
|
# Verify all cleaned up
|
|
chunks_after = (
|
|
test_session.query(DocumentChunk)
|
|
.filter_by(collection_name=collection_name)
|
|
.count()
|
|
)
|
|
assert chunks_after == 0
|
|
|
|
def test_delete_collection_removes_rag_index(
|
|
self, test_session, test_collection, test_rag_index
|
|
):
|
|
"""RAGIndex records are deleted with collection."""
|
|
collection_id = test_collection.id
|
|
collection_name = f"collection_{collection_id}"
|
|
|
|
# Verify RAG index exists
|
|
index_before = (
|
|
test_session.query(RAGIndex)
|
|
.filter_by(collection_name=collection_name)
|
|
.first()
|
|
)
|
|
assert index_before is not None, "RAG index should exist"
|
|
|
|
# Manually delete RAG index (should be done by service)
|
|
test_session.delete(index_before)
|
|
|
|
# Delete collection
|
|
collection = test_session.query(Collection).get(collection_id)
|
|
test_session.delete(collection)
|
|
test_session.commit()
|
|
|
|
# Verify RAG index is deleted
|
|
index_after = (
|
|
test_session.query(RAGIndex)
|
|
.filter_by(collection_name=collection_name)
|
|
.first()
|
|
)
|
|
assert index_after is None
|
|
|
|
|
|
class TestCollectionIndexDeletion:
|
|
"""Tests for deleting only the collection index."""
|
|
|
|
def test_delete_index_keeps_collection(
|
|
self, test_session, test_collection, test_chunks, test_rag_index
|
|
):
|
|
"""Collection remains after index deletion."""
|
|
collection_id = test_collection.id
|
|
collection_name = f"collection_{collection_id}"
|
|
|
|
# Delete chunks
|
|
test_session.query(DocumentChunk).filter_by(
|
|
collection_name=collection_name
|
|
).delete(synchronize_session=False)
|
|
|
|
# Delete RAG index
|
|
test_session.delete(test_rag_index)
|
|
test_session.commit()
|
|
|
|
# Verify collection still exists
|
|
collection = test_session.query(Collection).get(collection_id)
|
|
assert collection is not None, "Collection should still exist"
|
|
|
|
def test_delete_index_resets_document_status(
|
|
self, test_session, test_collection, test_documents, test_chunks
|
|
):
|
|
"""DocumentCollection indexed status is reset."""
|
|
collection_id = test_collection.id
|
|
collection_name = f"collection_{collection_id}"
|
|
|
|
# Delete chunks
|
|
test_session.query(DocumentChunk).filter_by(
|
|
collection_name=collection_name
|
|
).delete(synchronize_session=False)
|
|
|
|
# Reset document indexed status
|
|
updated = (
|
|
test_session.query(DocumentCollection)
|
|
.filter_by(collection_id=collection_id)
|
|
.update({"indexed": False, "chunk_count": 0})
|
|
)
|
|
assert updated == 3, "Should update 3 document links"
|
|
|
|
test_session.commit()
|
|
|
|
# Verify status is reset
|
|
links = (
|
|
test_session.query(DocumentCollection)
|
|
.filter_by(collection_id=collection_id)
|
|
.all()
|
|
)
|
|
for link in links:
|
|
assert link.indexed is False, "Should be marked as not indexed"
|
|
assert link.chunk_count == 0, "Chunk count should be 0"
|
|
|
|
|
|
class TestCollectionFolderCascade:
|
|
"""Tests for CollectionFolder cascade deletion."""
|
|
|
|
def test_delete_collection_removes_folders(
|
|
self, test_session, test_collection
|
|
):
|
|
"""CollectionFolders are deleted with collection."""
|
|
collection_id = test_collection.id
|
|
|
|
# Add a folder to the collection
|
|
folder = CollectionFolder(
|
|
collection_id=collection_id,
|
|
folder_path="/tmp/test_folder",
|
|
recursive=True,
|
|
)
|
|
test_session.add(folder)
|
|
test_session.commit()
|
|
|
|
# Verify folder exists
|
|
folder_before = (
|
|
test_session.query(CollectionFolder)
|
|
.filter_by(collection_id=collection_id)
|
|
.first()
|
|
)
|
|
assert folder_before is not None, "Folder should exist"
|
|
|
|
# Delete collection
|
|
collection = test_session.query(Collection).get(collection_id)
|
|
test_session.delete(collection)
|
|
test_session.commit()
|
|
|
|
# Verify folder is deleted (CASCADE)
|
|
folder_after = (
|
|
test_session.query(CollectionFolder)
|
|
.filter_by(collection_id=collection_id)
|
|
.first()
|
|
)
|
|
assert folder_after is None, "Folder should be deleted"
|
|
|
|
|
|
class TestOrphanedDocumentDeletion:
|
|
"""Tests for orphaned document deletion during collection deletion."""
|
|
|
|
def test_delete_collection_deletes_orphaned_documents(
|
|
self, test_session, test_collection, test_documents, source_type
|
|
):
|
|
"""Documents only in the deleted collection are deleted."""
|
|
collection_id = test_collection.id
|
|
doc_ids = [d.id for d in test_documents]
|
|
|
|
# Verify documents exist and are only in one collection
|
|
for doc_id in doc_ids:
|
|
link_count = (
|
|
test_session.query(DocumentCollection)
|
|
.filter_by(document_id=doc_id)
|
|
.count()
|
|
)
|
|
assert link_count == 1, (
|
|
"Each doc should be in exactly one collection"
|
|
)
|
|
|
|
# Manually delete links first
|
|
test_session.query(DocumentCollection).filter_by(
|
|
collection_id=collection_id
|
|
).delete(synchronize_session=False)
|
|
|
|
# Check for orphaned documents and delete them
|
|
orphaned_deleted = 0
|
|
for doc_id in doc_ids:
|
|
remaining = (
|
|
test_session.query(DocumentCollection)
|
|
.filter_by(document_id=doc_id)
|
|
.count()
|
|
)
|
|
if remaining == 0:
|
|
# Delete document
|
|
doc = test_session.query(Document).get(doc_id)
|
|
if doc:
|
|
test_session.delete(doc)
|
|
orphaned_deleted += 1
|
|
|
|
# Delete collection
|
|
collection = test_session.query(Collection).get(collection_id)
|
|
test_session.delete(collection)
|
|
test_session.commit()
|
|
|
|
# Verify orphaned documents were deleted
|
|
assert orphaned_deleted == 3, (
|
|
"All 3 orphaned documents should be deleted"
|
|
)
|
|
|
|
for doc_id in doc_ids:
|
|
doc = test_session.query(Document).get(doc_id)
|
|
assert doc is None, (
|
|
f"Orphaned document {doc_id[:8]} should be deleted"
|
|
)
|
|
|
|
def test_delete_collection_preserves_shared_documents(
|
|
self, test_session, test_collection, test_documents, source_type
|
|
):
|
|
"""Documents in other collections are preserved."""
|
|
collection_id = test_collection.id
|
|
doc_ids = [d.id for d in test_documents]
|
|
|
|
# Create a second collection and link the first document to it
|
|
second_collection = Collection(
|
|
id=str(uuid.uuid4()),
|
|
name="Second Collection",
|
|
)
|
|
test_session.add(second_collection)
|
|
test_session.flush()
|
|
|
|
# Link first document to second collection
|
|
shared_doc_id = doc_ids[0]
|
|
link = DocumentCollection(
|
|
document_id=shared_doc_id,
|
|
collection_id=second_collection.id,
|
|
indexed=False,
|
|
chunk_count=0,
|
|
)
|
|
test_session.add(link)
|
|
test_session.commit()
|
|
|
|
# Delete links for first collection
|
|
test_session.query(DocumentCollection).filter_by(
|
|
collection_id=collection_id
|
|
).delete(synchronize_session=False)
|
|
|
|
# Check orphaned docs
|
|
preserved_count = 0
|
|
deleted_count = 0
|
|
for doc_id in doc_ids:
|
|
remaining = (
|
|
test_session.query(DocumentCollection)
|
|
.filter_by(document_id=doc_id)
|
|
.count()
|
|
)
|
|
if remaining == 0:
|
|
doc = test_session.query(Document).get(doc_id)
|
|
if doc:
|
|
test_session.delete(doc)
|
|
deleted_count += 1
|
|
else:
|
|
preserved_count += 1
|
|
|
|
# Delete first collection
|
|
collection = test_session.query(Collection).get(collection_id)
|
|
test_session.delete(collection)
|
|
test_session.commit()
|
|
|
|
# Verify shared document was preserved
|
|
shared_doc = test_session.query(Document).get(shared_doc_id)
|
|
assert shared_doc is not None, "Shared document should be preserved"
|
|
assert preserved_count == 1, "One document should be preserved"
|
|
assert deleted_count == 2, "Two orphaned documents should be deleted"
|