Files
Drew Cain b997d858cd fix(sync): also exclude orphan DB projects absent from config (#949)
Address Codex review feedback. The previous path-only filter dropped an
implicit protection: get_project_mode() defaults projects missing from
config to CLOUD, so the old mode-based guard skipped stale DB rows that
had been removed from config. With a path-only check, an orphan row with
an absolute path would pass and background sync/watch could still mutate
a directory the user already removed from config (config is the source of
truth) if reconciliation was skipped or failed.

Introduce BasicMemoryConfig.is_locally_syncable(name, path), which
requires both config membership and an absolute path, and use it from
both the background sync selection and the watch cycle so the two paths
cannot diverge. Add direct unit tests for the helper plus an orphan-row
regression test for the watch selection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-06-12 18:02:18 -05:00

305 lines
10 KiB
Python

"""Integration-style tests for the initialization service.
Goal: avoid brittle deep mocking; assert real behavior using the existing
test config + dual-backend fixtures.
"""
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from basic_memory import db
from basic_memory.config import BasicMemoryConfig, DatabaseBackend
from basic_memory.repository.project_repository import ProjectRepository
from basic_memory.services.initialization import (
ensure_initialization,
initialize_app,
initialize_database,
initialize_file_sync,
reconcile_projects_with_config,
)
@pytest.mark.asyncio
async def test_initialize_database_creates_engine_and_allows_queries(app_config: BasicMemoryConfig):
await db.shutdown_db()
try:
await initialize_database(app_config)
engine, session_maker = await db.get_or_create_db(app_config.database_path)
assert engine is not None
assert session_maker is not None
# Smoke query on the initialized DB
async with db.scoped_session(session_maker) as session:
result = await session.execute(db.text("SELECT 1"))
assert result.scalar() == 1
finally:
await db.shutdown_db()
@pytest.mark.asyncio
async def test_initialize_database_raises_on_invalid_postgres_config(
app_config: BasicMemoryConfig, config_manager
):
"""If config selects Postgres but has no DATABASE_URL, initialization should fail."""
await db.shutdown_db()
try:
bad_config = app_config.model_copy(
update={"database_backend": DatabaseBackend.POSTGRES, "database_url": None}
)
config_manager.save_config(bad_config)
with pytest.raises(ValueError):
await initialize_database(bad_config)
finally:
await db.shutdown_db()
@pytest.mark.asyncio
async def test_reconcile_projects_with_config_creates_projects_and_default(
app_config: BasicMemoryConfig, config_manager, config_home
):
await db.shutdown_db()
try:
# Ensure the configured paths exist
proj_a = config_home / "proj-a"
proj_b = config_home / "proj-b"
proj_a.mkdir(parents=True, exist_ok=True)
proj_b.mkdir(parents=True, exist_ok=True)
from basic_memory.config import ProjectEntry
updated = app_config.model_copy(
update={
"projects": {
"proj-a": ProjectEntry(path=str(proj_a)),
"proj-b": ProjectEntry(path=str(proj_b)),
},
"default_project": "proj-b",
}
)
config_manager.save_config(updated)
# Real DB init + reconcile
await initialize_database(updated)
await reconcile_projects_with_config(updated)
_, session_maker = await db.get_or_create_db(
updated.database_path, db_type=db.DatabaseType.FILESYSTEM
)
repo = ProjectRepository(session_maker)
active = await repo.get_active_projects()
names = {p.name for p in active}
assert names.issuperset({"proj-a", "proj-b"})
default = await repo.get_default_project()
assert default is not None
assert default.name == "proj-b"
finally:
await db.shutdown_db()
@pytest.mark.asyncio
async def test_reconcile_projects_with_config_swallow_errors(
monkeypatch, app_config: BasicMemoryConfig
):
"""reconcile_projects_with_config should not raise if ProjectService sync fails."""
await db.shutdown_db()
try:
await initialize_database(app_config)
async def boom(self): # noqa: ANN001
raise ValueError("Project synchronization error")
monkeypatch.setattr(
"basic_memory.services.project_service.ProjectService.synchronize_projects",
boom,
)
# Should not raise
await reconcile_projects_with_config(app_config)
finally:
await db.shutdown_db()
def test_ensure_initialization_runs_and_cleans_up(app_config: BasicMemoryConfig, config_manager):
# ensure_initialization uses asyncio.run; keep this test synchronous.
ensure_initialization(app_config)
# Must be cleaned up to avoid hanging processes.
assert db._engine is None # pyright: ignore [reportPrivateUsage]
assert db._session_maker is None # pyright: ignore [reportPrivateUsage]
@pytest.mark.asyncio
async def test_initialize_app_warns_on_frontmatter_permalink_precedence(
app_config: BasicMemoryConfig, monkeypatch
):
app_config.database_backend = DatabaseBackend.SQLITE
app_config.ensure_frontmatter_on_sync = True
app_config.disable_permalinks = True
init_db_mock = AsyncMock()
reconcile_mock = AsyncMock()
monkeypatch.setattr("basic_memory.services.initialization.initialize_database", init_db_mock)
monkeypatch.setattr(
"basic_memory.services.initialization.reconcile_projects_with_config",
reconcile_mock,
)
warnings: list[str] = []
def capture_warning(message: str) -> None:
warnings.append(message)
monkeypatch.setattr("basic_memory.services.initialization.logger.warning", capture_warning)
await initialize_app(app_config)
assert init_db_mock.await_count == 1
assert reconcile_mock.await_count == 1
assert any(
"ensure_frontmatter_on_sync=True overrides disable_permalinks=True" in message
for message in warnings
)
class _FakeWatchService:
"""Captures init kwargs so tests can assert what the real service receives."""
last_kwargs: dict[str, object] = {}
def __init__(self, **kwargs):
_FakeWatchService.last_kwargs = kwargs
async def run(self):
return None
def _disable_test_env_short_circuit(monkeypatch) -> None:
"""Bypass ``is_test_env`` so ``initialize_file_sync`` actually runs.
``is_test_env`` returns True whenever pytest is running, which would cause
``initialize_file_sync`` to return before constructing a WatchService.
"""
monkeypatch.setattr(BasicMemoryConfig, "is_test_env", property(lambda self: False))
@pytest.mark.asyncio
async def test_initialize_file_sync_passes_constrained_project_to_watch_service(
app_config: BasicMemoryConfig, monkeypatch
):
"""``BASIC_MEMORY_MCP_PROJECT`` must reach the watch service, not just the
one-shot background sync. Otherwise multiple ``basic-memory mcp --project X``
processes each spawn a watcher over every project and race on file writes.
"""
_disable_test_env_short_circuit(monkeypatch)
monkeypatch.setenv("BASIC_MEMORY_MCP_PROJECT", "target-project")
monkeypatch.setattr("basic_memory.sync.WatchService", _FakeWatchService)
_FakeWatchService.last_kwargs = {}
await initialize_file_sync(app_config, quiet=True)
assert _FakeWatchService.last_kwargs.get("constrained_project") == "target-project"
@pytest.mark.asyncio
async def test_initialize_file_sync_no_constraint_when_env_unset(
app_config: BasicMemoryConfig, monkeypatch
):
"""With no env var set, the watch service is unconstrained."""
_disable_test_env_short_circuit(monkeypatch)
monkeypatch.delenv("BASIC_MEMORY_MCP_PROJECT", raising=False)
monkeypatch.setattr("basic_memory.sync.WatchService", _FakeWatchService)
_FakeWatchService.last_kwargs = {}
await initialize_file_sync(app_config, quiet=True)
assert _FakeWatchService.last_kwargs.get("constrained_project") is None
@pytest.mark.asyncio
async def test_initialize_file_sync_skips_project_with_non_absolute_path(
app_config: BasicMemoryConfig, config_manager, config_home, monkeypatch
):
"""Projects without an absolute local path are excluded from background sync (issue #949).
A config entry of ``{"path": ""}`` defaults to LOCAL mode and is not
recognized as cloud, yet Path("") resolves to the process cwd. Syncing it
would inject frontmatter into unrelated files, so it must be skipped.
"""
await db.shutdown_db()
try:
from basic_memory.config import ProjectEntry
good = config_home / "good"
good.mkdir(parents=True, exist_ok=True)
updated = app_config.model_copy(
update={
"projects": {
"good": ProjectEntry(path=str(good)),
# No mode -> defaults to LOCAL, empty (cwd-relative) path.
"empty-path": ProjectEntry(path=""),
},
"default_project": "good",
}
)
config_manager.save_config(updated)
await initialize_database(updated)
await reconcile_projects_with_config(updated)
_disable_test_env_short_circuit(monkeypatch)
monkeypatch.setattr("basic_memory.sync.WatchService", _FakeWatchService)
infos: list[str] = []
monkeypatch.setattr(
"basic_memory.services.initialization.logger.info",
lambda message, *args, **kwargs: infos.append(message),
)
await initialize_file_sync(updated, quiet=True)
skip_logs = [m for m in infos if "not locally syncable" in m]
assert skip_logs, "expected a skip log for the empty-path project"
assert "empty-path" in skip_logs[0]
assert "good" not in skip_logs[0]
finally:
await db.shutdown_db()
@pytest.mark.asyncio
async def test_initialize_app_no_precedence_warning_when_not_conflicting(
app_config: BasicMemoryConfig, monkeypatch
):
app_config.ensure_frontmatter_on_sync = False
app_config.disable_permalinks = True
monkeypatch.setattr(
"basic_memory.services.initialization.initialize_database",
AsyncMock(),
)
monkeypatch.setattr(
"basic_memory.services.initialization.reconcile_projects_with_config",
AsyncMock(),
)
warnings: list[str] = []
def capture_warning(message: str) -> None:
warnings.append(message)
monkeypatch.setattr("basic_memory.services.initialization.logger.warning", capture_warning)
await initialize_app(app_config)
assert not any(
"ensure_frontmatter_on_sync=True overrides disable_permalinks=True" in message
for message in warnings
)