2026-04-12 22:21:42 +00:00
|
|
|
"""Tests for destructive-operation safety in mempalace.migrate."""
|
|
|
|
|
|
2026-04-24 13:12:10 +09:00
|
|
|
import os
|
2026-04-12 22:21:42 +00:00
|
|
|
from types import SimpleNamespace
|
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
|
|
2026-04-24 13:12:10 +09:00
|
|
|
from mempalace.migrate import _restore_stale_palace, migrate
|
2026-04-12 22:21:42 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_migrate_requires_palace_database(tmp_path, capsys):
|
|
|
|
|
palace_dir = tmp_path / "palace"
|
|
|
|
|
palace_dir.mkdir()
|
|
|
|
|
|
|
|
|
|
result = migrate(str(palace_dir))
|
|
|
|
|
|
|
|
|
|
out = capsys.readouterr().out
|
|
|
|
|
assert result is False
|
|
|
|
|
assert "No palace database found" in out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_migrate_aborts_without_confirmation(tmp_path, capsys):
|
|
|
|
|
palace_dir = tmp_path / "palace"
|
|
|
|
|
palace_dir.mkdir()
|
2026-04-12 22:27:40 +00:00
|
|
|
# Presence of chroma.sqlite3 is the safety gate; validity is mocked below.
|
2026-04-12 22:21:42 +00:00
|
|
|
(palace_dir / "chroma.sqlite3").write_text("db")
|
|
|
|
|
|
2026-04-12 22:24:41 +00:00
|
|
|
mock_chromadb = SimpleNamespace(
|
|
|
|
|
__version__="0.6.0",
|
|
|
|
|
PersistentClient=MagicMock(side_effect=Exception("unreadable")),
|
|
|
|
|
)
|
2026-04-12 22:21:42 +00:00
|
|
|
|
|
|
|
|
with (
|
|
|
|
|
patch.dict("sys.modules", {"chromadb": mock_chromadb}),
|
|
|
|
|
patch("mempalace.migrate.detect_chromadb_version", return_value="0.5.x"),
|
|
|
|
|
patch(
|
|
|
|
|
"mempalace.migrate.extract_drawers_from_sqlite",
|
|
|
|
|
return_value=[{"id": "id1", "document": "doc", "metadata": {"wing": "w", "room": "r"}}],
|
|
|
|
|
),
|
|
|
|
|
patch("builtins.input", return_value="n"),
|
|
|
|
|
patch("mempalace.migrate.shutil.copytree") as mock_copytree,
|
|
|
|
|
patch("mempalace.migrate.shutil.rmtree") as mock_rmtree,
|
|
|
|
|
):
|
|
|
|
|
result = migrate(str(palace_dir))
|
|
|
|
|
|
|
|
|
|
out = capsys.readouterr().out
|
|
|
|
|
assert result is False
|
|
|
|
|
assert "Aborted." in out
|
|
|
|
|
mock_copytree.assert_not_called()
|
|
|
|
|
mock_rmtree.assert_not_called()
|
2026-04-24 13:12:10 +09:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_restore_stale_palace_with_clean_destination(tmp_path):
|
|
|
|
|
"""Rollback when no partial copy exists at palace_path."""
|
|
|
|
|
palace_path = tmp_path / "palace"
|
|
|
|
|
stale_path = tmp_path / "palace.old"
|
|
|
|
|
stale_path.mkdir()
|
|
|
|
|
(stale_path / "chroma.sqlite3").write_bytes(b"original")
|
|
|
|
|
|
|
|
|
|
_restore_stale_palace(str(palace_path), str(stale_path))
|
|
|
|
|
|
|
|
|
|
assert palace_path.is_dir()
|
|
|
|
|
assert (palace_path / "chroma.sqlite3").read_bytes() == b"original"
|
|
|
|
|
assert not stale_path.exists()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_restore_stale_palace_clears_partial_copy(tmp_path):
|
|
|
|
|
"""Rollback must remove a partially-copied palace_path before restoring.
|
|
|
|
|
|
|
|
|
|
Simulates the Qodo-reported hazard: shutil.move() began creating
|
|
|
|
|
palace_path, then failed. A bare os.replace(stale, palace_path) would
|
|
|
|
|
trip on the existing destination; _restore_stale_palace must clear it.
|
|
|
|
|
"""
|
|
|
|
|
palace_path = tmp_path / "palace"
|
|
|
|
|
stale_path = tmp_path / "palace.old"
|
|
|
|
|
|
|
|
|
|
stale_path.mkdir()
|
|
|
|
|
(stale_path / "chroma.sqlite3").write_bytes(b"original")
|
|
|
|
|
|
|
|
|
|
palace_path.mkdir()
|
|
|
|
|
(palace_path / "half-copied.bin").write_bytes(b"garbage")
|
|
|
|
|
|
|
|
|
|
_restore_stale_palace(str(palace_path), str(stale_path))
|
|
|
|
|
|
|
|
|
|
assert palace_path.is_dir()
|
|
|
|
|
assert (palace_path / "chroma.sqlite3").read_bytes() == b"original"
|
|
|
|
|
assert not (palace_path / "half-copied.bin").exists()
|
|
|
|
|
assert not stale_path.exists()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_restore_stale_palace_logs_and_swallows_on_failure(tmp_path, capsys):
|
|
|
|
|
"""If restore itself fails, log both paths — don't raise from rollback."""
|
|
|
|
|
palace_path = tmp_path / "palace"
|
|
|
|
|
stale_path = tmp_path / "palace.old"
|
|
|
|
|
stale_path.mkdir()
|
|
|
|
|
|
|
|
|
|
# Force os.replace to fail deterministically.
|
|
|
|
|
with patch("mempalace.migrate.os.replace", side_effect=OSError("boom")):
|
|
|
|
|
_restore_stale_palace(str(palace_path), str(stale_path))
|
|
|
|
|
|
|
|
|
|
out = capsys.readouterr().out
|
|
|
|
|
assert "CRITICAL" in out
|
|
|
|
|
assert os.fspath(palace_path) in out
|
|
|
|
|
assert os.fspath(stale_path) in out
|