- API: POST /api/compact (optional body {"files": [...]})
- CLI: python main.py compact [--db FILE]
- Local path: rename → CompactDatabase(bak→src) → delete bak (zero copy)
- UNC path: copy to local temp → compact → copy back (avoids DAO segfault)
- Uses DAO DBEngine.CompactDatabase via pywin32 COM
- new dep: pywin32>=306
Co-Authored-By: Claude <noreply@anthropic.com>
247 lines
8.9 KiB
Python
247 lines
8.9 KiB
Python
"""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"
|