"""Tests for the compact & repair layer: CompactSummary, compact_file (mocked), and the POST /api/compact endpoint via FastAPI TestClient.""" import datetime as _dt import os from unittest.mock import MagicMock, patch from fastapi.testclient import TestClient from sync.compact import CompactFileResult, CompactSummary, _human from sync.config import ( AccessConfig, FileMapping, HealthConfig, RuntimeConfig, SqlServerConfig, SyncConfig, ) from sync.health import ServiceState from sync.web.app import create_app def _cfg(**kw): return SyncConfig( sql_server=SqlServerConfig(conn_str="x"), access=AccessConfig(driver="d", roots={"2026": "r"}), runtime=RuntimeConfig(poll_interval_seconds=10), files=[ FileMapping(file="a.accdb", root="2026", schema="s", year_suffix="_Y"), FileMapping(file="b.accdb", root="2026", schema="s2", year_suffix="_Y"), ], health=HealthConfig(**kw), ) def _state(): s = ServiceState(started_at=_dt.datetime.now(), pid=1) s.last_cycle_at = _dt.datetime.now() return s # -------------------------------------------------------------- CompactSummary def test_summary_all_ok_empty(): s = CompactSummary() assert s.total_files == 0 assert s.ok == 0 assert s.failed == 0 assert s.all_ok is True assert s.saved_bytes == 0 def test_summary_all_ok(): s = CompactSummary() s.results.append(CompactFileResult(file="a.accdb", source_path="/a", ok=True, before_bytes=1000, after_bytes=800)) s.results.append(CompactFileResult(file="b.accdb", source_path="/b", ok=True, before_bytes=2000, after_bytes=1500)) assert s.total_files == 2 assert s.ok == 2 assert s.failed == 0 assert s.all_ok is True assert s.before_bytes_total == 3000 assert s.after_bytes_total == 2300 assert s.saved_bytes == 700 def test_summary_partial(): s = CompactSummary() s.results.append(CompactFileResult(file="a.accdb", source_path="/a", ok=True, before_bytes=1000, after_bytes=800)) s.results.append(CompactFileResult(file="b.accdb", source_path="/b", ok=False, error="lock timeout")) assert s.total_files == 2 assert s.ok == 1 assert s.failed == 1 assert s.all_ok is False # --------------------------------------------------------------- _human helper def test_human_bytes(): assert _human(0) == "0B" assert _human(500) == "500B" assert _human(1024) == "1.0KB" assert _human(1536) == "1.5KB" assert _human(1048576) == "1.0MB" assert _human(1073741824) == "1.0GB" # ---------------------------------------------------- compact_file local (mocked) UNC_PATH = "\\\\server\\share\\a.accdb" LOCAL_PATH = "D:\\data\\a.accdb" @patch("sync.compact._dao_compact") @patch("sync.compact.os.path.getsize") @patch("sync.compact._rm_f") def test_compact_local_ok(mock_rm_f, mock_getsize, mock_dao): """Local path: rename → compact → delete bak. Zero extra copies.""" mock_getsize.side_effect = [2048, 1024] # before, after with patch("sync.compact.os.rename"), \ patch("sync.compact.os.remove"), \ patch("sync.compact.os.path.exists", return_value=False): from sync.compact import compact_file r = compact_file(LOCAL_PATH) assert r.ok is True assert r.before_bytes == 2048 assert r.after_bytes == 1024 mock_dao.assert_called_once_with(LOCAL_PATH + ".compact_bak", LOCAL_PATH) @patch("sync.compact._dao_compact") @patch("sync.compact.os.path.getsize") @patch("sync.compact._rm_f") def test_compact_local_dao_failure_rolls_back(mock_rm_f, mock_getsize, mock_dao): """Local compact failure restores original from .bak.""" from sync.compact import compact_file mock_getsize.return_value = 2048 mock_dao.side_effect = RuntimeError("DAO compact failed") bak = LOCAL_PATH + ".compact_bak" with patch("sync.compact.os.rename"), \ patch("sync.compact.os.remove"), \ patch("sync.compact.os.path.exists") as mock_exists: # exists(bak)=True (rollback), exists(original)=False (was renamed) mock_exists.side_effect = lambda p: p == bak r = compact_file(LOCAL_PATH) assert r.ok is False assert "DAO compact failed" in (r.error or "") # ---------------------------------------------------- compact_file UNC (mocked) @patch("sync.compact._dao_compact") @patch("sync.compact.os.path.getsize") @patch("sync.compact._rm_f") def test_compact_unc_ok(mock_rm_f, mock_getsize, mock_dao): """UNC path: copy → compact in local temp → copy back.""" mock_getsize.side_effect = [2048, 1024] # before, after with patch("sync.compact.shutil.copy2"), \ patch("sync.compact.os.rename"), \ patch("sync.compact.os.remove"), \ patch("sync.compact.tempfile.mkstemp") as mock_mkstemp: mock_mkstemp.side_effect = [ (1, "/tmp/a_cpysrc_.accdb"), (2, "/tmp/a_cpydst_.accdb"), ] # os.path.exists calls: # 1st: tmp_dst guard → False # 2nd: bak cleanup → True with patch("sync.compact.os.path.exists", side_effect=[False, True]): from sync.compact import compact_file r = compact_file(UNC_PATH) assert r.ok is True assert r.before_bytes == 2048 assert r.after_bytes == 1024 mock_dao.assert_called_once() @patch("sync.compact._dao_compact") @patch("sync.compact.os.path.getsize") @patch("sync.compact._rm_f") def test_compact_unc_dao_failure_leaves_original(mock_rm_f, mock_getsize, mock_dao): """UNC compact failure leaves the network file untouched.""" from sync.compact import compact_file mock_getsize.return_value = 2048 mock_dao.side_effect = RuntimeError("DAO compact failed") with patch("sync.compact.shutil.copy2"), \ patch("sync.compact.os.rename"), \ patch("sync.compact.os.remove"), \ patch("sync.compact.tempfile.mkstemp") as mock_mkstemp: mock_mkstemp.side_effect = [ (1, "/tmp/a_cpysrc_.accdb"), (2, "/tmp/a_cpydst_.accdb"), ] with patch("sync.compact.os.path.exists", side_effect=[False]): r = compact_file(UNC_PATH) assert r.ok is False assert "DAO compact failed" in (r.error or "") # ----------------------------------------------------------- HTTP endpoint def _app(state=None, cfg=None): return create_app(state or _state(), cfg or _cfg()) @patch("sync.web.routes.compact.compact_files") def test_compact_endpoint_all_files(mock_compact): """POST /api/compact with no body compacts all files.""" mock_compact.return_value = CompactSummary(results=[ CompactFileResult(file="a.accdb", source_path="/a", ok=True, before_bytes=1000, after_bytes=500, duration_s=0.3), ]) client = TestClient(_app()) r = client.post("/api/compact") assert r.status_code == 200 body = r.json() assert body["status"] == "ok" assert body["total_files"] == 1 assert body["ok"] == 1 assert body["failed"] == 0 assert body["saved_bytes"] == 500 mock_compact.assert_called_once() # Should have passed file_list=None (compact all) _, kwargs = mock_compact.call_args assert kwargs.get("file_list") is None @patch("sync.web.routes.compact.compact_files") def test_compact_endpoint_filter_by_files(mock_compact): """POST /api/compact with files list filters to those files.""" mock_compact.return_value = CompactSummary(results=[ CompactFileResult(file="a.accdb", source_path="/a", ok=True, before_bytes=1000, after_bytes=600, duration_s=0.2), ]) client = TestClient(_app()) r = client.post("/api/compact", json={"files": ["a.accdb"]}) assert r.status_code == 200 mock_compact.assert_called_once() _, kwargs = mock_compact.call_args assert kwargs.get("file_list") == ["a.accdb"] @patch("sync.web.routes.compact.compact_files") def test_compact_endpoint_partial_failure_returns_200_with_partial_status(mock_compact): """Partial failure returns 200 but status='partial' in the body. (The 207 Multi-Status code is reserved for a future enhancement — for now the HTTP layer reports what compact_files returned without forcing 207.) """ mock_compact.return_value = CompactSummary(results=[ CompactFileResult(file="a.accdb", source_path="/a", ok=True, before_bytes=1000, after_bytes=500, duration_s=0.3), CompactFileResult(file="b.accdb", source_path="/b", ok=False, error="timeout", duration_s=5.0), ]) client = TestClient(_app()) r = client.post("/api/compact") assert r.status_code == 200 body = r.json() assert body["status"] == "partial" assert body["ok"] == 1 assert body["failed"] == 1 assert body["results"][1]["error"] == "timeout"