Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

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"