- m018_fts_from_normalized_tags: replace FTS triggers to read from asset_tags+tags instead of the legacy JSON asset_metadata.tags column - m019_drop_legacy_tag_columns: remove asset_metadata.tags and tags_text via create-copy-swap; includes invariant check (aborts if legacy tags not backfilled) - registry.py: MIGRATIONS = [M017, M018, M019] - schema_fts.py / schema_sql.py: FTS helpers and baseline triggers rewritten to source from normalized tables with asset_tags guard - TagsRepository: normalized read path (get_tags_for_asset, list_assets_with_tag, search_assets_by_tags) + sync_from_legacy_row repair helper - Readers v2: searcher, result_hydrator, metadata_helpers, updater, rating_tags_service, rename_service, stacks/service, duplicates/service migrated to asset_tags/tags JOIN - Stop-Write: writes no longer update asset_metadata.tags / tags_text - sqlite_facade: ahas_table helper; sqlite_recovery updated - deps.py: TagsRepository wired into service container - scripts/tag_drift_doctor.py: detects assets with legacy tags not backfilled - ADR 0009, ARCHITECTURE_MAP, RATINGS_TAGS_COLLECTIONS, adr/README updated - Tests: test_drop_legacy_tag_columns (2), test_fts_from_normalized (6), test_tags_dual_write, test_tags_normalized_readers + regression fixes across test_rating_tags_import, test_index_metadata_helpers, test_index_searcher, test_asset_updater, test_deps, test_schema_heal - Full suite: 1687 passed, 2 skipped, 2 pre-existing failures (test_health_routes)
243 lines
7.7 KiB
Python
243 lines
7.7 KiB
Python
from pathlib import Path
|
|
|
|
import pytest
|
|
from mjr_am_backend import deps as deps_mod
|
|
from mjr_am_backend.shared import Result
|
|
|
|
|
|
def _stub_versioned_migrations(monkeypatch) -> None:
|
|
"""Replace the versioned MigrationRunner so deps tests can mock the DB."""
|
|
from mjr_am_backend.adapters.db import migrations as _migrations_mod
|
|
|
|
class _Runner:
|
|
def __init__(self, *_args, **_kwargs):
|
|
pass
|
|
|
|
async def run(self, _db):
|
|
return Result.Ok([])
|
|
|
|
monkeypatch.setattr(_migrations_mod, "MigrationRunner", _Runner)
|
|
|
|
|
|
def test_resolve_db_path_default_and_custom(monkeypatch):
|
|
monkeypatch.setattr(deps_mod, "get_runtime_index_db_path", lambda: Path("C:/default.sqlite"))
|
|
assert deps_mod._resolve_db_path(None).replace("\\", "/") == "C:/default.sqlite"
|
|
assert deps_mod._resolve_db_path("C:/x.sqlite") == "C:/x.sqlite"
|
|
|
|
|
|
def test_resolve_vectors_db_path_default_and_custom(monkeypatch):
|
|
monkeypatch.setattr(deps_mod, "get_runtime_index_db_path", lambda: Path("C:/default.sqlite"))
|
|
monkeypatch.setattr(deps_mod, "get_runtime_vectors_db_path", lambda: Path("C:/shared/vectors.sqlite"))
|
|
|
|
assert deps_mod._resolve_vectors_db_path("C:/default.sqlite").replace("\\", "/") == "C:/shared/vectors.sqlite"
|
|
assert deps_mod._resolve_vectors_db_path("C:/tmp/custom.sqlite").endswith("tmp\\vectors.sqlite")
|
|
|
|
|
|
def test_init_db_or_error_failure(monkeypatch):
|
|
class _BadSqlite:
|
|
def __init__(self, *_args, **_kwargs):
|
|
raise RuntimeError("boom")
|
|
|
|
monkeypatch.setattr(deps_mod, "Sqlite", _BadSqlite)
|
|
out = deps_mod._init_db_or_error("C:/db.sqlite")
|
|
assert out.ok is False
|
|
assert out.code == "DB_ERROR"
|
|
|
|
|
|
def test_init_db_or_error_uses_sibling_vectors_db_for_custom_path(monkeypatch):
|
|
captured: dict[str, object] = {}
|
|
|
|
class _Sqlite:
|
|
def __init__(self, db_path, **kwargs):
|
|
captured["db_path"] = db_path
|
|
captured["kwargs"] = kwargs
|
|
|
|
monkeypatch.setattr(deps_mod, "Sqlite", _Sqlite)
|
|
monkeypatch.setattr(deps_mod, "get_runtime_index_db_path", lambda: Path("C:/default.sqlite"))
|
|
monkeypatch.setattr(deps_mod, "get_runtime_vectors_db_path", lambda: Path("C:/shared/vectors.sqlite"))
|
|
|
|
out = deps_mod._init_db_or_error("C:/tmp/custom.sqlite")
|
|
assert out.ok is True
|
|
kwargs = captured["kwargs"]
|
|
assert isinstance(kwargs, dict)
|
|
attach = kwargs.get("attach")
|
|
assert isinstance(attach, dict)
|
|
assert str(attach.get("vec") or "").endswith("tmp\\vectors.sqlite")
|
|
|
|
|
|
def test_resolve_db_path_uses_runtime_override(monkeypatch, tmp_path):
|
|
override_db = tmp_path / "override-index" / "assets.sqlite"
|
|
monkeypatch.setattr(deps_mod, "get_runtime_index_db_path", lambda: override_db)
|
|
|
|
assert deps_mod._resolve_db_path(None) == str(override_db)
|
|
|
|
|
|
def test_build_services_dict_contains_core_keys(monkeypatch):
|
|
class _Dup:
|
|
def __init__(self, db):
|
|
self.db = db
|
|
|
|
monkeypatch.setattr(deps_mod, "DuplicatesService", _Dup)
|
|
|
|
out = deps_mod._build_services_dict(
|
|
db="db",
|
|
exiftool="exif",
|
|
ffprobe="ff",
|
|
metadata_service="meta",
|
|
health_service="health",
|
|
index_service="index",
|
|
settings_service="settings",
|
|
)
|
|
|
|
assert out["db"] == "db"
|
|
assert out["duplicates"].db == "db"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_migrate_db_or_error_failure(monkeypatch):
|
|
async def _migrate(_db):
|
|
return Result.Err("DB_ERROR", "migrate failed")
|
|
|
|
monkeypatch.setattr(deps_mod, "migrate_schema", _migrate)
|
|
out = await deps_mod._migrate_db_or_error(object())
|
|
assert out.ok is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_build_services_migration_failure(monkeypatch):
|
|
class _Sqlite:
|
|
def __init__(self, *_args, **_kwargs):
|
|
pass
|
|
|
|
async def _migrate(_db):
|
|
return Result.Err("DB_ERROR", "no")
|
|
|
|
monkeypatch.setattr(deps_mod, "Sqlite", _Sqlite)
|
|
monkeypatch.setattr(deps_mod, "migrate_schema", _migrate)
|
|
|
|
out = await deps_mod.build_services("C:/db.sqlite")
|
|
assert out.ok is False
|
|
assert out.code == "DB_ERROR"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_build_services_success_without_watcher(monkeypatch):
|
|
class _Sqlite:
|
|
def __init__(self, *_args, **_kwargs):
|
|
self.db = "ok"
|
|
|
|
class _Exif:
|
|
def __init__(self, *_args, **_kwargs):
|
|
pass
|
|
|
|
def is_available(self):
|
|
return True
|
|
|
|
class _Ff:
|
|
def __init__(self, *_args, **_kwargs):
|
|
pass
|
|
|
|
def is_available(self):
|
|
return True
|
|
|
|
class _Settings:
|
|
def __init__(self, _db):
|
|
pass
|
|
|
|
async def ensure_security_bootstrap(self):
|
|
return None
|
|
|
|
class _Metadata:
|
|
def __init__(self, **_kwargs):
|
|
pass
|
|
|
|
class _Health:
|
|
def __init__(self, **_kwargs):
|
|
pass
|
|
|
|
class _Index:
|
|
def __init__(self, **kwargs):
|
|
self.db = kwargs.get("db")
|
|
|
|
class _Dup:
|
|
def __init__(self, _db):
|
|
pass
|
|
|
|
class _Sync:
|
|
def __init__(self, _exif):
|
|
pass
|
|
|
|
async def _migrate(_db):
|
|
return Result.Ok(True)
|
|
|
|
async def _load_scope(_db):
|
|
return {"scope": "output", "custom_root_id": ""}
|
|
|
|
monkeypatch.setattr(deps_mod, "WATCHER_ENABLED", False)
|
|
monkeypatch.setattr(deps_mod, "Sqlite", _Sqlite)
|
|
monkeypatch.setattr(deps_mod, "ExifTool", _Exif)
|
|
monkeypatch.setattr(deps_mod, "FFProbe", _Ff)
|
|
monkeypatch.setattr(deps_mod, "AppSettings", _Settings)
|
|
monkeypatch.setattr(deps_mod, "MetadataService", _Metadata)
|
|
monkeypatch.setattr(deps_mod, "HealthService", _Health)
|
|
monkeypatch.setattr(deps_mod, "IndexService", _Index)
|
|
monkeypatch.setattr(deps_mod, "DuplicatesService", _Dup)
|
|
monkeypatch.setattr(deps_mod, "RatingTagsSyncWorker", _Sync)
|
|
monkeypatch.setattr(deps_mod, "migrate_schema", _migrate)
|
|
monkeypatch.setattr(deps_mod, "load_watcher_scope", _load_scope)
|
|
_stub_versioned_migrations(monkeypatch)
|
|
|
|
out = await deps_mod.build_services("C:/db.sqlite")
|
|
assert out.ok is True
|
|
data = out.data
|
|
assert "index" in data
|
|
assert data.get("watcher") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_watcher_starts_and_callbacks(monkeypatch):
|
|
calls = {"index": 0, "remove": 0, "rename": 0}
|
|
|
|
class _Idx:
|
|
db = object()
|
|
|
|
async def index_paths(self, *args, **kwargs):
|
|
_ = (args, kwargs)
|
|
calls["index"] += 1
|
|
return Result.Ok({})
|
|
|
|
async def remove_file(self, *_args, **_kwargs):
|
|
calls["remove"] += 1
|
|
return Result.Ok({})
|
|
|
|
async def rename_file(self, *_args, **_kwargs):
|
|
calls["rename"] += 1
|
|
return Result.Ok({})
|
|
|
|
class _Watcher:
|
|
def __init__(self, index_callback, remove_callback=None, move_callback=None):
|
|
self._index_callback = index_callback
|
|
self._remove_callback = remove_callback
|
|
self._move_callback = move_callback
|
|
self.started = False
|
|
|
|
async def start(self, _paths, _loop):
|
|
self.started = True
|
|
await self._index_callback(["C:/x.png"], "C:/", "watcher", None)
|
|
if self._remove_callback:
|
|
await self._remove_callback(["C:/x.png"], "C:/", "watcher", None)
|
|
if self._move_callback:
|
|
await self._move_callback([("C:/x.png", "C:/y.png")], "C:/", "watcher", None)
|
|
|
|
async def _load_scope(_db):
|
|
return {"scope": "output", "custom_root_id": ""}
|
|
|
|
monkeypatch.setattr(deps_mod, "OutputWatcher", _Watcher)
|
|
monkeypatch.setattr(deps_mod, "load_watcher_scope", _load_scope)
|
|
monkeypatch.setattr(deps_mod, "build_watch_paths", lambda *_args, **_kwargs: [{"path": "C:/"}])
|
|
|
|
watcher = await deps_mod._create_watcher(_Idx())
|
|
assert watcher.started is True
|
|
assert calls["index"] >= 1
|
|
assert calls["remove"] >= 1
|