feat: cleanup phase and main service loop with error isolation
Wires capture -> apply -> cleanup into cycle(cfg): per-file capture and cleanup each wrapped in try/except + log.exception so one file's failure does not abort the cycle; apply failure does not block cleanup; writer is always closed in finally. run(cfg) loops cycle with sleep; main() loads config from argv. logging_setup uses RotatingFileHandler 10MBx5 + console. Unit tests cover all three error-isolation branches via mocks (no real end-to-end smoke; integration deferred to Task 9 pilot). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
83
tests/test_service.py
Normal file
83
tests/test_service.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Unit tests for the service cycle: error isolation + apply-still-called.
|
||||
|
||||
These verify the orchestration contract of ``cycle()`` only. ``run()`` is an
|
||||
infinite loop and is deliberately not tested here; real end-to-end smoke is
|
||||
deferred to the Task 9 pilot.
|
||||
"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from sync.config import (
|
||||
SyncConfig,
|
||||
AccessConfig,
|
||||
RuntimeConfig,
|
||||
SqlServerConfig,
|
||||
FileMapping,
|
||||
)
|
||||
from sync import service
|
||||
|
||||
|
||||
def _cfg():
|
||||
return SyncConfig(
|
||||
sql_server=SqlServerConfig(conn_str="x"),
|
||||
access=AccessConfig(driver="d", roots={"2026": "r"}),
|
||||
runtime=RuntimeConfig(),
|
||||
files=[
|
||||
FileMapping(file="ok.accdb", root="2026", schema="s", year_suffix="_Y"),
|
||||
FileMapping(file="bad.accdb", root="2026", schema="s", year_suffix="_Y"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_cycle_continues_on_capture_error_and_still_applies_and_cleans():
|
||||
"""A capture failure on one file must not abort the cycle.
|
||||
|
||||
Verify: (a) capture is attempted for both files, (b) apply is still
|
||||
called, (c) cleanup is attempted for both files, (d) the writer is
|
||||
closed in the finally block.
|
||||
"""
|
||||
cfg = _cfg()
|
||||
with patch.object(service, "SqlWriter") as MW, \
|
||||
patch.object(service, "AccessReader") as MR, \
|
||||
patch.object(service, "capture_file", side_effect=[5, RuntimeError("boom")]) as mcapture, \
|
||||
patch.object(service, "cleanup_file", return_value=0) as mcleanup:
|
||||
writer_inst = MagicMock()
|
||||
MW.return_value = writer_inst
|
||||
MR.return_value = MagicMock() # reader instance mock
|
||||
service.cycle(cfg) # must not raise
|
||||
assert mcapture.call_count == 2 # both files attempted (bad swallowed)
|
||||
writer_inst.call_apply.assert_called_once() # apply still called
|
||||
assert mcleanup.call_count == 2 # cleanup attempted for both files
|
||||
writer_inst.close.assert_called() # writer closed in finally
|
||||
|
||||
|
||||
def test_cycle_apply_failure_does_not_block_cleanup():
|
||||
"""Apply failure must not block the cleanup phase."""
|
||||
cfg = _cfg()
|
||||
with patch.object(service, "SqlWriter") as MW, \
|
||||
patch.object(service, "AccessReader") as MR, \
|
||||
patch.object(service, "capture_file", return_value=3) as mcapture, \
|
||||
patch.object(service, "cleanup_file", return_value=7) as mcleanup:
|
||||
writer_inst = MagicMock()
|
||||
writer_inst.call_apply.side_effect = RuntimeError("apply boom")
|
||||
MW.return_value = writer_inst
|
||||
MR.return_value = MagicMock()
|
||||
service.cycle(cfg) # must not raise
|
||||
assert mcapture.call_count == 2 # both files captured
|
||||
writer_inst.call_apply.assert_called_once() # apply attempted
|
||||
assert mcleanup.call_count == 2 # cleanup still runs despite apply failure
|
||||
writer_inst.close.assert_called()
|
||||
|
||||
|
||||
def test_cycle_cleanup_failure_does_not_abort_other_files():
|
||||
"""A cleanup failure on one file must not skip cleanup of the other."""
|
||||
cfg = _cfg()
|
||||
with patch.object(service, "SqlWriter") as MW, \
|
||||
patch.object(service, "AccessReader") as MR, \
|
||||
patch.object(service, "capture_file", return_value=1), \
|
||||
patch.object(service, "cleanup_file", side_effect=[0, RuntimeError("cleanup boom")]) as mcleanup:
|
||||
writer_inst = MagicMock()
|
||||
MW.return_value = writer_inst
|
||||
MR.return_value = MagicMock()
|
||||
service.cycle(cfg) # must not raise
|
||||
assert mcleanup.call_count == 2 # second file cleanup still attempted
|
||||
writer_inst.close.assert_called()
|
||||
Reference in New Issue
Block a user