feat(compact): Access database compact & repair API + CLI

- 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>
This commit is contained in:
Misaka_Company
2026-08-06 11:23:43 +08:00
parent 79a95465e7
commit 42a2f99ed9
7 changed files with 749 additions and 0 deletions

246
tests/test_compact.py Normal file
View File

@@ -0,0 +1,246 @@
"""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"