Files
MajoorWaldi-ComfyUI-Majoor-…/tests/core/test_deps.py
T
Ewald a363a77a6b feat(db): Phase C tag normalization — v18/v19 migrations, normalized FTS, readers v2, stop-write, drop legacy columns
- 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)
2026-05-22 15:00:27 +02:00

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