Files
kefu/wechat_rpa/test_archive_upload_flow.py
T
2026-09-21 10:34:06 +08:00

136 lines
8.6 KiB
Python

"""Cloud destination, startup, recovery and status regressions; no real network/data."""
import json
import tempfile
import unittest
from pathlib import Path
from unittest import mock
import archive_auto_backup as archive
import backend_client as backend
class ArchiveUploadFlowTest(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.source = self.root / "WXWork"
self.message = self.source / "100" / "Data" / "message.db"
self.message.parent.mkdir(parents=True)
self.message.touch()
self.config = archive.AutoBackupConfig(self.root, self.source, self.root / "archive_auto_backup", (backend.PACKAGED_SERVER_URL,))
self.decrypted = [(str(self.root / "message.db"), "message.db", "100")]
for patcher in (
mock.patch.object(archive, "application_data_dir", return_value=self.root),
mock.patch.object(archive, "_backup_policy", return_value=(True, 1)),
mock.patch.dict(archive.os.environ, {"WECOM_ARCHIVE_AUTO_BACKUP": "1", "WECOM_ARCHIVE_API_URL": ""}),
):
patcher.start()
self.addCleanup(patcher.stop)
def test_source_uses_configured_cloud_without_local_fallback(self):
cloud = "http://archive.example.invalid:8369"
with mock.patch.object(archive, "is_frozen", return_value=False), mock.patch.object(backend, "is_frozen", return_value=False), mock.patch.object(backend, "load_settings", return_value={"server_url": cloud}):
self.assertEqual(archive._api_candidates(), [cloud])
def test_installed_build_ignores_stale_override(self):
with mock.patch.object(archive, "is_frozen", return_value=True), mock.patch.object(backend, "is_frozen", return_value=True), mock.patch.dict(archive.os.environ, {"WECOM_ARCHIVE_API_URL": "http://old.example.invalid"}):
self.assertEqual(archive._api_candidates("http://127.0.0.1:8766"), [backend.PACKAGED_SERVER_URL])
def test_source_retains_explicit_development_endpoint(self):
with mock.patch.object(archive, "is_frozen", return_value=False):
self.assertEqual(archive._api_candidates("http://localhost:8766/"), ["http://localhost:8766"])
def test_source_local_legacy_port_fallback(self):
with mock.patch.object(archive, "is_frozen", return_value=False), mock.patch.object(backend, "is_frozen", return_value=False), mock.patch.object(backend, "load_settings", return_value={"server_url": "http://127.0.0.1:8765"}):
self.assertEqual(archive._api_candidates(), ["http://127.0.0.1:8765", "http://127.0.0.1:8766"])
def test_desktop_keys_allow_upload_without_legacy_key_file(self):
with mock.patch.object(archive, "_load_exporter_keys", side_effect=archive.ArchiveBackupError("missing")), mock.patch("wxwork_db.load_keys", return_value={"100": "11" * 16}), mock.patch("wxwork_db.decrypt_with_keys", return_value=self.decrypted) as decrypt, mock.patch("wxwork_local_setup.acquire_local_keys") as acquire:
self.assertEqual(archive._prepare_archive_databases(self.config), self.decrypted)
self.assertEqual(decrypt.call_args.args[2], {"100": "11" * 16})
acquire.assert_not_called()
def test_missing_keys_are_acquired_once_then_decryption_retried(self):
with mock.patch.object(archive, "_load_exporter_keys", return_value={}), mock.patch("wxwork_db.load_keys", side_effect=[{}, {"100": "22" * 16}]), mock.patch("wxwork_db.decrypt_with_keys", side_effect=[[], self.decrypted]) as decrypt, mock.patch("wxwork_local_setup.select_source_directory") as select, mock.patch("wxwork_local_setup.acquire_local_keys") as acquire:
self.assertEqual(archive._prepare_archive_databases(self.config), self.decrypted)
acquire.assert_called_once_with()
select.assert_called_once_with(str(self.source))
self.assertEqual(decrypt.call_count, 2)
def test_key_acquisition_failure_is_recorded_without_upload(self):
with mock.patch.object(archive, "_configured_source_root", return_value=self.source), mock.patch.object(archive, "_load_exporter_modules", return_value=(None, None)), mock.patch.object(archive, "_load_exporter_keys", return_value={}), mock.patch("wxwork_db.load_keys", return_value={}), mock.patch("wxwork_db.decrypt_with_keys", return_value=[]), mock.patch("wxwork_local_setup.select_source_directory"), mock.patch("wxwork_local_setup.acquire_local_keys", side_effect=ValueError("please log in to WeCom")), mock.patch.object(archive.ArchiveApiClient, "connect") as connect:
with self.assertRaisesRegex(archive.ArchiveBackupError, "please log in to WeCom"):
archive.run_backup_once(self.config)
connect.assert_not_called()
self.assertEqual(archive.read_backup_status()["status"], "failed")
self.assertFalse((self.config.work_root / "backup.lock").exists())
def test_cloud_failure_is_recorded_for_retry(self):
with mock.patch.object(archive, "_configured_source_root", return_value=self.source), mock.patch.object(archive, "_load_exporter_modules", return_value=(None, None)), mock.patch.object(archive, "_prepare_archive_databases", return_value=self.decrypted), mock.patch.object(archive.ArchiveApiClient, "connect", side_effect=archive.ArchiveBackupError("HTTP 401")):
with self.assertRaisesRegex(archive.ArchiveBackupError, "401"):
archive.run_backup_once(self.config)
self.assertIn("401", archive.read_backup_status()["message"])
self.assertFalse((self.config.work_root / "backup.lock").exists())
def run_scheduler(self, enabled):
class Clock:
now = 0
waits = 0
def is_set(self):
return self.waits >= 4
def wait(self, seconds):
self.now += seconds
self.waits += 1
return self.is_set()
clock = Clock()
runs = []
with mock.patch.object(archive, "_STOP_EVENT", clock), mock.patch.object(archive.time, "monotonic", side_effect=lambda: clock.now), mock.patch.object(archive, "_refresh_cloud_policy"), mock.patch.object(archive, "_backup_policy", return_value=(enabled, 1)), mock.patch.object(archive, "_run_worker_with_retries", side_effect=lambda *args: runs.append(clock.now)):
archive._launch_worker(self.config, 0)
return runs
def test_startup_uploads_immediately_then_respects_interval(self):
self.assertEqual(self.run_scheduler(True), [0, 60])
def test_disabled_cloud_policy_never_launches_upload(self):
self.assertEqual(self.run_scheduler(False), [])
def save_status(self, payload):
self.config.work_root.mkdir(exist_ok=True)
(self.config.work_root / "status.json").write_text(json.dumps(payload), encoding="utf-8-sig")
def test_status_handles_missing_and_malformed_file(self):
self.assertEqual(archive.read_backup_status()["status"], "waiting")
for payload in ([], {"status": "completed", "summary": "bad"}, {"status": "completed", "completed_at": "bad"}):
self.save_status(payload)
self.assertEqual(archive.read_backup_status()["status"], "waiting")
def test_status_shows_success_and_running_without_raw_fields(self):
for status in ("running", "completed"):
self.save_status({"status": status, "completed_at": 1000, "summary": {"inserted": 23}, "source_root": "private-path"})
result = archive.read_backup_status()
self.assertEqual(result["status"], status)
self.assertEqual(set(result), {"status", "message"})
if status == "completed":
self.assertIn("23", result["message"])
def test_status_removes_signed_storage_url_query(self):
self.save_status({"status": "failed", "error": "upload failed: https://u:p@storage.example.invalid/file?q-signature=secret-signature&q-ak=secret-id"})
result = archive.read_backup_status()["message"]
for secret in ("secret-signature", "secret-id", "u:p@"):
self.assertNotIn(secret, result)
self.assertIn("storage.example.invalid/file", result)
def test_disabled_policy_overrides_old_success(self):
self.save_status({"status": "completed", "summary": {"inserted": 1}})
with mock.patch.object(archive, "_backup_policy", return_value=(False, 1)):
self.assertEqual(archive.read_backup_status()["status"], "disabled")
def test_encrypted_database_to_cloud_client_and_checkpoint(self):
from packaging_checks import check_database_initialization
check_database_initialization()
if __name__ == "__main__":
unittest.main()