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

420 lines
13 KiB
Python

"""
Tests for document deletion functionality.
Tests the DocumentDeletionService methods:
- delete_document
- delete_blob_only
- remove_from_collection
"""
import pytest
import uuid
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from local_deep_research.database.models.library import (
Document,
DocumentBlob,
DocumentChunk,
DocumentCollection,
Collection,
SourceType,
)
from local_deep_research.constants import (
FILE_PATH_BLOB_DELETED,
)
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:")
# Enable foreign key enforcement for SQLite (required for CASCADE to work)
@event.listens_for(engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
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."""
collection = Collection(
id=str(uuid.uuid4()),
name="Test Collection",
description="A test collection",
)
test_session.add(collection)
test_session.commit()
return collection
@pytest.fixture
def test_document(test_session, source_type, test_collection):
"""Create a test document with blob and collection link."""
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,
file_type="pdf",
title="Test Document",
filename="test.pdf",
storage_mode="database",
text_content="This is test content.",
)
test_session.add(doc)
test_session.flush()
# Add blob
blob = DocumentBlob(
document_id=doc_id,
pdf_binary=b"fake pdf content",
blob_hash="fake_hash",
)
test_session.add(blob)
# Add to collection
doc_collection = DocumentCollection(
document_id=doc_id,
collection_id=test_collection.id,
indexed=False,
)
test_session.add(doc_collection)
test_session.commit()
return doc
class TestDocumentDeletionService:
"""Unit tests for DocumentDeletionService."""
def test_delete_document_removes_blob(self, test_session, test_document):
"""Verify DocumentBlob is deleted with document."""
doc_id = test_document.id
# Verify blob exists before deletion
blob_before = (
test_session.query(DocumentBlob)
.filter_by(document_id=doc_id)
.first()
)
assert blob_before is not None, "Blob should exist before deletion"
# Delete document (manually simulating service behavior)
doc = test_session.query(Document).get(doc_id)
test_session.delete(doc)
test_session.commit()
# Verify blob is deleted (CASCADE should handle this)
blob_after = (
test_session.query(DocumentBlob)
.filter_by(document_id=doc_id)
.first()
)
assert blob_after is None, "Blob should be deleted with document"
def test_delete_document_removes_collection_links(
self, test_session, test_document, test_collection
):
"""Verify DocumentCollection links are deleted."""
doc_id = test_document.id
# Verify link exists before deletion
link_before = (
test_session.query(DocumentCollection)
.filter_by(document_id=doc_id)
.first()
)
assert link_before is not None, "Collection link should exist"
# Delete document
doc = test_session.query(Document).get(doc_id)
test_session.delete(doc)
test_session.commit()
# Verify link is deleted
link_after = (
test_session.query(DocumentCollection)
.filter_by(document_id=doc_id)
.first()
)
assert link_after is None, "Collection link should be deleted"
def test_delete_blob_keeps_document(self, test_session, test_document):
"""Verify document metadata and text_content preserved after blob deletion."""
doc_id = test_document.id
# Delete only the blob
blob = (
test_session.query(DocumentBlob)
.filter_by(document_id=doc_id)
.first()
)
test_session.delete(blob)
test_session.commit()
# Verify document still exists with text
doc = test_session.query(Document).get(doc_id)
assert doc is not None, "Document should still exist"
assert doc.text_content is not None, "Text content should be preserved"
assert doc.title == "Test Document", "Title should be preserved"
def test_remove_from_collection_deletes_orphan(
self, test_session, test_document, test_collection
):
"""Document in single collection: deleted completely."""
doc_id = test_document.id
# Remove the collection link (simulate remove_from_collection)
link = (
test_session.query(DocumentCollection)
.filter_by(document_id=doc_id, collection_id=test_collection.id)
.first()
)
test_session.delete(link)
test_session.flush()
# Check if document is in any collection
remaining = (
test_session.query(DocumentCollection)
.filter_by(document_id=doc_id)
.count()
)
assert remaining == 0, "Document should not be in any collection"
# If orphaned, delete document
if remaining == 0:
doc = test_session.query(Document).get(doc_id)
test_session.delete(doc)
test_session.commit()
# Verify document is deleted
doc_after = test_session.query(Document).get(doc_id)
assert doc_after is None, "Orphaned document should be deleted"
def test_remove_from_collection_keeps_if_in_other(
self, test_session, test_document, test_collection
):
"""Document in multiple collections: only unlinked."""
doc_id = test_document.id
# Create another collection and link document to it
other_collection = Collection(
id=str(uuid.uuid4()),
name="Other Collection",
)
test_session.add(other_collection)
test_session.flush()
other_link = DocumentCollection(
document_id=doc_id,
collection_id=other_collection.id,
indexed=False,
)
test_session.add(other_link)
test_session.commit()
# Remove from first collection
link = (
test_session.query(DocumentCollection)
.filter_by(document_id=doc_id, collection_id=test_collection.id)
.first()
)
test_session.delete(link)
test_session.commit()
# Document should still exist (in other collection)
doc = test_session.query(Document).get(doc_id)
assert doc is not None, "Document should exist (in other collection)"
# Document should only be in other collection
links = (
test_session.query(DocumentCollection)
.filter_by(document_id=doc_id)
.all()
)
assert len(links) == 1, "Document should be in exactly one collection"
assert links[0].collection_id == other_collection.id
class TestBlobDeletion:
"""Unit tests for blob-only deletion."""
def test_delete_blob_updates_storage_mode(
self, test_session, test_document
):
"""Verify storage_mode set to 'none' after blob deletion."""
doc_id = test_document.id
# Delete blob and update storage mode
blob = (
test_session.query(DocumentBlob)
.filter_by(document_id=doc_id)
.first()
)
test_session.delete(blob)
doc = test_session.query(Document).get(doc_id)
doc.storage_mode = "none"
doc.file_path = FILE_PATH_BLOB_DELETED
test_session.commit()
# Verify update
doc_after = test_session.query(Document).get(doc_id)
assert doc_after.storage_mode == "none"
assert doc_after.file_path == FILE_PATH_BLOB_DELETED
def test_delete_blob_frees_bytes(self, test_session, test_document):
"""Verify bytes freed calculation is correct."""
doc_id = test_document.id
# Get blob size
blob = (
test_session.query(DocumentBlob)
.filter_by(document_id=doc_id)
.first()
)
blob_size = len(blob.pdf_binary) if blob.pdf_binary else 0
assert blob_size > 0, "Blob should have content"
# Delete blob
test_session.delete(blob)
test_session.commit()
# Verify blob is deleted
blob_after = (
test_session.query(DocumentBlob)
.filter_by(document_id=doc_id)
.first()
)
assert blob_after is None
class TestDocumentChunkCleanup:
"""Tests for DocumentChunk cleanup (no FK constraint)."""
def test_chunks_require_manual_cleanup(
self, test_session, test_document, test_collection
):
"""Verify DocumentChunks must be manually deleted (no FK)."""
doc_id = test_document.id
collection_name = f"collection_{test_collection.id}"
# Create some chunks for the document
for i in range(3):
chunk = DocumentChunk(
chunk_hash=f"chunk_hash_{i}_{uuid.uuid4().hex[:8]}",
source_type="document",
source_id=doc_id,
collection_name=collection_name,
chunk_text=f"Test chunk {i} content",
chunk_index=i,
start_char=i * 100,
end_char=(i + 1) * 100,
word_count=10,
embedding_id=str(uuid.uuid4()),
embedding_model="test-model",
embedding_model_type="sentence_transformers",
)
test_session.add(chunk)
test_session.commit()
# Verify chunks exist
chunks_before = (
test_session.query(DocumentChunk)
.filter_by(source_id=doc_id, source_type="document")
.count()
)
assert chunks_before == 3, "Should have 3 chunks"
# Delete document WITHOUT manually deleting chunks first
doc = test_session.query(Document).get(doc_id)
test_session.delete(doc)
test_session.commit()
# Chunks should still exist (no FK constraint!)
chunks_after = (
test_session.query(DocumentChunk)
.filter_by(source_id=doc_id, source_type="document")
.count()
)
# This shows the orphan problem - chunks remain after document deletion
assert chunks_after == 3, "Chunks remain orphaned (no FK)"
def test_manual_chunk_deletion(
self, test_session, test_document, test_collection
):
"""Verify manual chunk deletion works."""
doc_id = test_document.id
collection_name = f"collection_{test_collection.id}"
# Create a chunk
chunk = DocumentChunk(
chunk_hash=f"chunk_hash_{uuid.uuid4().hex[:8]}",
source_type="document",
source_id=doc_id,
collection_name=collection_name,
chunk_text="Test chunk content",
chunk_index=0,
start_char=0,
end_char=100,
word_count=10,
embedding_id=str(uuid.uuid4()),
embedding_model="test-model",
embedding_model_type="sentence_transformers",
)
test_session.add(chunk)
test_session.commit()
# Manually delete chunks before document
deleted = (
test_session.query(DocumentChunk)
.filter(
DocumentChunk.source_id == doc_id,
DocumentChunk.source_type == "document",
)
.delete(synchronize_session=False)
)
assert deleted == 1, "Should delete 1 chunk"
# Now delete document
doc = test_session.query(Document).get(doc_id)
test_session.delete(doc)
test_session.commit()
# Verify all cleaned up
doc_after = test_session.query(Document).get(doc_id)
assert doc_after is None