import json import sqlite3 import subprocess import sys import tempfile import unittest from pathlib import Path from unittest import mock ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "tools")) import sync_orchestrator # noqa: E402 import sync_peer_worker # noqa: E402 class SyncErrorClassificationTest(unittest.TestCase): def test_recovery_envelope_includes_consumed_and_rejected_acknowledgements(self): with sqlite3.connect(":memory:") as database: database.execute( "CREATE TABLE sync_consumed_generations(" "generation_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT," "consumed_at TEXT,result TEXT,ack_json TEXT)" ) for generation, result in (("gen_consumed", "consumed"), ("gen_rejected", "rejected"), ("gen_other", "consumed")): database.execute( "INSERT INTO sync_consumed_generations VALUES(?,?,?,?,?,?)", (generation, "android" if generation != "gen_other" else "other", "desktop", generation, result, json.dumps({"generation_id": generation, "result": result})), ) acknowledgements = sync_peer_worker.recovery_acknowledgements( database, "android", "desktop" ) self.assertEqual( [(value["generation_id"], value["result"]) for value in acknowledgements], [("gen_consumed", "consumed"), ("gen_rejected", "rejected")], ) def test_mtp_refreshes_a_retained_peer_advertisement_before_validation(self): expected_peer = "peer_00000000-0000-4000-8000-000000000001" with tempfile.TemporaryDirectory() as directory: root = Path(directory) peer_path = root / "android-peer-v1.json" peer_path.write_text( json.dumps( { "format": "trainlog-sync-peer", "version": 1, "peer_id": expected_peer, "capabilities": sorted( sync_peer_worker.CAPS - {"session-preparations-v2"} ), } ) ) pulls = [] def refresh(_adapter, operation, peer, transport, _deadline, allow_missing): pulls.append((operation, peer, transport, allow_missing)) peer_path.write_text( json.dumps( { "format": "trainlog-sync-peer", "version": 1, "peer_id": expected_peer, "capabilities": sorted(sync_peer_worker.CAPS), } ) ) return True with mock.patch.object(sync_peer_worker, "run_adapter", side_effect=refresh): sync_peer_worker.refresh_mtp_peer( Path("/adapter"), expected_peer, root, sync_peer_worker.time.monotonic() + 60, ) value = sync_peer_worker.load_peer(root, expected_peer) self.assertEqual(value["peer_id"], expected_peer) self.assertEqual( pulls, [("pull", expected_peer, root, True)], ) def test_transport_timeout_has_a_stable_code_and_bounded_diagnostic(self): timeout = subprocess.TimeoutExpired(["adapter", "push"], 30) with mock.patch.object(sync_peer_worker.subprocess, "run", side_effect=timeout): with self.assertRaisesRegex( RuntimeError, r"^transport_timeout: MTP push did not finish within 30 seconds$", ) as caught: sync_peer_worker.run_adapter( Path("/adapter"), "push", "peer_00000000-0000-4000-8000-000000000001", Path("/transport"), sync_peer_worker.time.monotonic() + 60, ) self.assertEqual(sync_orchestrator.failure_code(caught.exception), "transport_timeout") def test_known_operational_failures_remain_distinct(self): self.assertEqual( sync_orchestrator.failure_code(RuntimeError("expected Android peer not found")), "device_unavailable", ) self.assertEqual( sync_orchestrator.failure_code(RuntimeError("pending capacity exhausted")), "capacity_exhausted", ) self.assertEqual( sync_orchestrator.failure_code(RuntimeError("revision conflict")), "data_conflict", ) def test_mtp_outbox_excludes_retained_staging_and_unrelated_files(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) / "transport" root.mkdir() (root / "request-v1.json").write_text("{}") (root / "staging").mkdir() (root / "staging" / "retained.json").write_text("retained") (root / "legacy.json").write_text("legacy") observed = [] def inspect(command, **_kwargs): outbox = Path(command[3]) observed.extend( str(path.relative_to(outbox)) for path in outbox.rglob("*") if path.is_file() ) return subprocess.CompletedProcess(command, 0, b"", b"") with mock.patch.object(sync_peer_worker.subprocess, "run", side_effect=inspect): sync_peer_worker.push_adapter( Path("/adapter"), "peer_00000000-0000-4000-8000-000000000001", root, ["request-v1.json"], sync_peer_worker.time.monotonic() + 60, ) self.assertEqual(observed, ["request-v1.json"]) def test_desktop_generation_directory_precedes_reference(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) generation = ( "desktop-objects/generations/" "gen_11111111-1111-4111-8111-111111111111" ) (root / generation).mkdir(parents=True) (root / generation / "manifest.json").write_text("manifest") for name in ( "request-v1.json", "desktop-archive-acknowledgements-v1.json", "desktop-consumption-ack-v1.json", "desktop-generation-v1.json", ): (root / name).write_text("{}") observed = [] def inspect(_adapter, _operation, _peer, outbox, _deadline): observed.append( sorted( str(path.relative_to(outbox)) for path in outbox.rglob("*") if path.is_file() ) ) return True with mock.patch.object(sync_peer_worker, "run_adapter", side_effect=inspect): sync_peer_worker.push_desktop_generation( Path("adapter"), "peer", root, generation, sync_peer_worker.time.monotonic() + 60, ) self.assertEqual(len(observed), 2) self.assertEqual(observed[0], [generation + "/manifest.json"]) self.assertNotIn("desktop-generation-v1.json", observed[0]) self.assertIn("desktop-generation-v1.json", observed[1]) if __name__ == "__main__": unittest.main()