Files
ProductionDataBaseSync_Data…/tests/test_service.py
Misaka_Company ea48de290f fix(sync): track cleanup state to stop re-deleting log rows and bound SyncQueue
- access_reader.delete_log_ids returns the actual rows deleted (was None).

- sql_writer.mark_cleaned flips applied queue rows to 'cleaned' (sets CleanedAt) after their Access log rows are physically removed, so the same IDs are never deleted twice.

- sql_writer.purge_cleaned removes 'cleaned' rows older than a retention window (default 24h) so SyncQueue stops growing without bound.

- cleanup.cleanup_file marks rows cleaned after a successful delete and returns the real delete count, so the service log reports honest 'cleaned N' instead of a constant.

- service.cycle calls purge_cleaned once per pass; config adds cleaned_retention_hours (default 24).

- sql/01_sync_queue.sql adds CleanedAt column + IX_SyncQueue_Cleaned idempotently.

- tests: unit coverage for mark_cleaned/purge_cleaned/delete_log_ids return count; assert cycle purges each pass.
2026-07-14 16:32:47 +08:00

85 lines
3.6 KiB
Python

"""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.purge_cleaned.assert_called_once() # cleanup bookkeeping
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()