Archived
158 lines
6.0 KiB
Python
158 lines
6.0 KiB
Python
"""Tests for SQLite collection persistence."""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from recoll_modern.domain.errors import AppError
|
|
from recoll_modern.domain.models import CollectionCreate, CollectionPatch
|
|
from recoll_modern.storage.collections import SQLiteCollectionRepository
|
|
from recoll_modern.storage.database import Database
|
|
|
|
|
|
@pytest.fixture
|
|
def database(tmp_path: Path) -> Database:
|
|
"""Provide a migrated database for each repository test."""
|
|
result = Database(tmp_path / "recoll-modern.sqlite3")
|
|
result.migrate()
|
|
return result
|
|
|
|
|
|
@pytest.fixture
|
|
def repository(database: Database) -> SQLiteCollectionRepository:
|
|
"""Create the repository under test."""
|
|
return SQLiteCollectionRepository(database)
|
|
|
|
|
|
def test_create_assigns_opaque_id_and_persists_optional_description(
|
|
repository: SQLiteCollectionRepository,
|
|
) -> None:
|
|
"""Fail if creation leaks a caller-controlled ID or loses the description."""
|
|
collection = repository.create(CollectionCreate(name="Tech Books", description="Shelf one"))
|
|
|
|
assert collection.id.startswith("c")
|
|
assert len(collection.id) == 11
|
|
assert collection.name == "Tech Books"
|
|
assert collection.description == "Shelf one"
|
|
assert repository.get(collection.id) == collection
|
|
|
|
|
|
def test_create_maps_normalized_name_conflict_to_app_error(
|
|
repository: SQLiteCollectionRepository,
|
|
) -> None:
|
|
"""Fail if equivalent Unicode names expose a raw SQLite uniqueness error."""
|
|
repository.create(CollectionCreate(name="Stra\u00dfe"))
|
|
|
|
with pytest.raises(AppError) as caught:
|
|
repository.create(CollectionCreate(name="STRASSE"))
|
|
|
|
assert caught.value.code == "collection_name_taken"
|
|
assert caught.value.status == 409
|
|
|
|
|
|
def test_list_returns_page_and_total_count(repository: SQLiteCollectionRepository) -> None:
|
|
"""Fail if list pagination omits the full collection count or returns the wrong page."""
|
|
first = repository.create(CollectionCreate(name="One"))
|
|
second = repository.create(CollectionCreate(name="Two"))
|
|
repository.create(CollectionCreate(name="Three"))
|
|
|
|
page, total = repository.list(limit=1, offset=1)
|
|
|
|
assert total == 3
|
|
assert page == [second]
|
|
assert first not in page
|
|
|
|
|
|
def test_patch_updates_only_requested_fields(repository: SQLiteCollectionRepository) -> None:
|
|
"""Fail if a rename alters an unchanged description or does not persist the new name."""
|
|
collection = repository.create(CollectionCreate(name="Tech Books", description="Shelf one"))
|
|
|
|
changed = repository.patch(collection.id, CollectionPatch(name="Reference"))
|
|
|
|
assert changed.name == "Reference"
|
|
assert changed.description == "Shelf one"
|
|
assert repository.get(collection.id) == changed
|
|
|
|
|
|
def test_patch_can_clear_description(repository: SQLiteCollectionRepository) -> None:
|
|
"""Fail if an explicit null description cannot remove existing metadata."""
|
|
collection = repository.create(CollectionCreate(name="Tech Books", description="Shelf one"))
|
|
|
|
changed = repository.patch(collection.id, CollectionPatch(description=None))
|
|
|
|
assert changed.description is None
|
|
assert repository.get(collection.id) == changed
|
|
|
|
|
|
def test_delete_returns_canonical_paths_before_memberships_cascade(
|
|
database: Database, repository: SQLiteCollectionRepository
|
|
) -> None:
|
|
"""Fail if deletion loses the paths that must later be reindexed."""
|
|
collection = repository.create(CollectionCreate(name="Tech Books"))
|
|
with database.transaction() as connection:
|
|
connection.execute(
|
|
"INSERT INTO documents VALUES (?, ?, ?, ?, ?)",
|
|
("a" * 64, 1, "present", "2026-09-18T00:00:00+00:00", "2026-09-18T00:00:00+00:00"),
|
|
)
|
|
connection.execute(
|
|
"INSERT INTO document_paths "
|
|
"(document_hash, relative_path, is_canonical, last_seen_at, missing_at) "
|
|
"VALUES (?, ?, ?, ?, ?)",
|
|
("a" * 64, "books/a.pdf", 1, "2026-09-18T00:00:00+00:00", None),
|
|
)
|
|
connection.execute(
|
|
"INSERT INTO direct_memberships VALUES (?, ?, ?)",
|
|
(collection.id, "a" * 64, "2026-09-18T00:00:00+00:00"),
|
|
)
|
|
|
|
paths = repository.delete(collection.id)
|
|
|
|
assert paths == ["books/a.pdf"]
|
|
assert repository.get(collection.id) is None
|
|
with database.connect() as connection:
|
|
assert connection.execute("SELECT COUNT(*) FROM direct_memberships").fetchone()[0] == 0
|
|
|
|
|
|
def test_delete_includes_non_excluded_rule_memberships(
|
|
database: Database, repository: SQLiteCollectionRepository
|
|
) -> None:
|
|
"""Fail if deletion misses canonical paths contributed by persistent rules."""
|
|
collection = repository.create(CollectionCreate(name="Tech Books"))
|
|
with database.transaction() as connection:
|
|
connection.execute(
|
|
"INSERT INTO documents VALUES (?, ?, ?, ?, ?)",
|
|
("b" * 64, 1, "present", "2026-09-18T00:00:00+00:00", "2026-09-18T00:00:00+00:00"),
|
|
)
|
|
connection.execute(
|
|
"INSERT INTO document_paths "
|
|
"(document_hash, relative_path, is_canonical, last_seen_at, missing_at) "
|
|
"VALUES (?, ?, ?, ?, ?)",
|
|
("b" * 64, "books/b.pdf", 1, "2026-09-18T00:00:00+00:00", None),
|
|
)
|
|
connection.execute(
|
|
"INSERT INTO directory_rules VALUES (?, ?, ?, ?, ?)",
|
|
(
|
|
"r1",
|
|
collection.id,
|
|
"books",
|
|
"2026-09-18T00:00:00+00:00",
|
|
"2026-09-18T00:00:00+00:00",
|
|
),
|
|
)
|
|
connection.execute(
|
|
"INSERT INTO rule_memberships VALUES (?, ?, ?)",
|
|
("r1", "b" * 64, "2026-09-18T00:00:00+00:00"),
|
|
)
|
|
|
|
assert repository.delete(collection.id) == ["books/b.pdf"]
|
|
|
|
|
|
def test_repository_does_not_leak_sqlite_error_for_name_conflicts(
|
|
repository: SQLiteCollectionRepository,
|
|
) -> None:
|
|
"""Fail if the domain boundary exposes database implementation failures."""
|
|
repository.create(CollectionCreate(name="Same"))
|
|
|
|
with pytest.raises(AppError):
|
|
repository.create(CollectionCreate(name="same"))
|