205 lines
6.6 KiB
Python
205 lines
6.6 KiB
Python
import stat
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
from pgu4_sync import (
|
|
DELETE_BATCH_ITEMS,
|
|
DELETE_RETRIES,
|
|
DeleteWorker,
|
|
RemoteEntry,
|
|
)
|
|
|
|
|
|
class FakeChannel:
|
|
def __init__(self, status):
|
|
self.status = status
|
|
|
|
def recv_exit_status(self):
|
|
return self.status
|
|
|
|
|
|
class FakeStream:
|
|
def __init__(self, status=0, data=b""):
|
|
self.channel = FakeChannel(status)
|
|
self.data = data
|
|
|
|
def read(self):
|
|
return self.data
|
|
|
|
|
|
class FakeClient:
|
|
def __init__(self, responder=None):
|
|
self.commands = []
|
|
self.responder = responder or (lambda _command: (0, b""))
|
|
|
|
def exec_command(self, command):
|
|
self.commands.append(command)
|
|
status, error = self.responder(command)
|
|
return None, FakeStream(status), FakeStream(status, error)
|
|
|
|
def close(self):
|
|
return None
|
|
|
|
|
|
class FakeFactory:
|
|
def __init__(self, client=None, error=None):
|
|
self.client = client
|
|
self.error = error
|
|
|
|
def open_client(self):
|
|
if self.error is not None:
|
|
raise self.error
|
|
return self.client
|
|
|
|
|
|
def entry(path, is_dir=False):
|
|
mode = stat.S_IFDIR if is_dir else stat.S_IFREG
|
|
return RemoteEntry(path.rsplit("/", 1)[-1], path, 0, 0, mode)
|
|
|
|
|
|
class DeleteWorkerTests(unittest.TestCase):
|
|
def run_worker(self, worker):
|
|
failures = []
|
|
progress = []
|
|
finished = []
|
|
worker.failed.connect(lambda path, message: failures.append((path, message)))
|
|
worker.progress.connect(lambda done, total, path: progress.append((done, total, path)))
|
|
worker.finished.connect(lambda deleted, failed: finished.append((deleted, failed)))
|
|
worker.run()
|
|
return failures, progress, finished
|
|
|
|
def test_quotes_paths_and_uses_permanent_rm(self):
|
|
client = FakeClient()
|
|
worker = DeleteWorker(
|
|
FakeFactory(client),
|
|
[entry("/tmp/space dir", True), entry("/tmp/quote'file")],
|
|
)
|
|
|
|
failures, progress, finished = self.run_worker(worker)
|
|
|
|
self.assertEqual([], failures)
|
|
self.assertEqual([(2, 0)], finished)
|
|
self.assertEqual((2, 2), progress[-1][:2])
|
|
self.assertEqual(1, len(client.commands))
|
|
self.assertTrue(
|
|
client.commands[0].startswith(
|
|
"rm -rf -- '/tmp/space dir' '/tmp/quote'\"'\"'file'"
|
|
)
|
|
)
|
|
self.assertIn("Path still exists after removal", client.commands[0])
|
|
|
|
def test_rejects_root_and_noncanonical_paths(self):
|
|
unsafe_paths = [
|
|
"/",
|
|
"//",
|
|
"////",
|
|
"/tmp/../etc",
|
|
"/tmp//item",
|
|
"relative",
|
|
"",
|
|
"/tmp/item\x00tail",
|
|
]
|
|
factory = FakeFactory(error=AssertionError("SSH must not be opened"))
|
|
|
|
for path in unsafe_paths:
|
|
with self.subTest(path=path):
|
|
failures, progress, finished = self.run_worker(
|
|
DeleteWorker(factory, [entry(path, True)])
|
|
)
|
|
self.assertEqual(1, len(failures))
|
|
self.assertEqual([(0, 1)], finished)
|
|
self.assertEqual((1, 1), progress[-1][:2])
|
|
|
|
def test_splits_large_selections_into_batches(self):
|
|
client = FakeClient()
|
|
entries = [entry(f"/tmp/item_{index:04d}") for index in range(300)]
|
|
worker = DeleteWorker(FakeFactory(client), entries)
|
|
|
|
failures, progress, finished = self.run_worker(worker)
|
|
|
|
self.assertEqual([], failures)
|
|
self.assertEqual([(300, 0)], finished)
|
|
self.assertEqual(3, len(client.commands))
|
|
self.assertEqual((300, 300), progress[-1][:2])
|
|
self.assertEqual(
|
|
[DELETE_BATCH_ITEMS, DELETE_BATCH_ITEMS, 44],
|
|
[
|
|
command.split(" && if ", 1)[0].count("/tmp/item_")
|
|
for command in client.commands
|
|
],
|
|
)
|
|
|
|
@patch("pgu4_sync.time.sleep")
|
|
def test_batch_failure_falls_back_to_individual_paths(self, _sleep_mock):
|
|
def responder(command):
|
|
remove_command = command.split(" && if ", 1)[0]
|
|
if remove_command.count("/tmp/") > 1:
|
|
return 1, b"batch failed"
|
|
if "/tmp/bad" in remove_command:
|
|
return 1, b"permission denied"
|
|
return 0, b""
|
|
|
|
client = FakeClient(responder)
|
|
worker = DeleteWorker(
|
|
FakeFactory(client),
|
|
[entry("/tmp/good"), entry("/tmp/bad")],
|
|
)
|
|
|
|
failures, _progress, finished = self.run_worker(worker)
|
|
|
|
self.assertEqual(1, len(failures))
|
|
self.assertEqual("/tmp/bad", failures[0][0])
|
|
self.assertIn("permission denied", failures[0][1])
|
|
self.assertIn(f"重试 {DELETE_RETRIES} 次", failures[0][1])
|
|
self.assertEqual([(1, 1)], finished)
|
|
self.assertGreaterEqual(len(client.commands), DELETE_RETRIES + 2)
|
|
|
|
@patch("pgu4_sync.time.sleep")
|
|
def test_transient_directory_not_empty_is_retried(self, sleep_mock):
|
|
attempts = {"count": 0}
|
|
|
|
def responder(_command):
|
|
attempts["count"] += 1
|
|
if attempts["count"] < 3:
|
|
return 1, b"Directory not empty"
|
|
return 0, b""
|
|
|
|
client = FakeClient(responder)
|
|
worker = DeleteWorker(FakeFactory(client), [entry("/tmp/busy", True)])
|
|
|
|
failures, progress, finished = self.run_worker(worker)
|
|
|
|
self.assertEqual([], failures)
|
|
self.assertEqual([(1, 0)], finished)
|
|
self.assertEqual((1, 1), progress[-1][:2])
|
|
self.assertEqual(3, attempts["count"])
|
|
self.assertEqual(2, sleep_mock.call_count)
|
|
|
|
@patch("pgu4_sync.time.sleep")
|
|
def test_persistent_directory_not_empty_has_clear_message(self, _sleep_mock):
|
|
client = FakeClient(lambda _command: (1, b"Directory not empty"))
|
|
worker = DeleteWorker(FakeFactory(client), [entry("/tmp/busy", True)])
|
|
|
|
failures, _progress, finished = self.run_worker(worker)
|
|
|
|
self.assertEqual([(0, 1)], finished)
|
|
self.assertEqual(1, len(failures))
|
|
self.assertIn("其他程序正在持续写入", failures[0][1])
|
|
self.assertIn(f"重试 {DELETE_RETRIES} 次", failures[0][1])
|
|
|
|
def test_connection_failure_reports_every_selected_item(self):
|
|
worker = DeleteWorker(
|
|
FakeFactory(error=TimeoutError("connect timeout")),
|
|
[entry("/tmp/a"), entry("/tmp/b")],
|
|
)
|
|
|
|
failures, progress, finished = self.run_worker(worker)
|
|
|
|
self.assertEqual({"/tmp/a", "/tmp/b"}, {path for path, _message in failures})
|
|
self.assertEqual((2, 2), progress[-1][:2])
|
|
self.assertEqual([(0, 2)], finished)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|