文件

138 行
6.1 KiB
Python

"""Exercise the PC client against a real local HTTP socket, without Android."""
import importlib.util
import json
from pathlib import Path
import tempfile
import threading
import unittest
from unittest.mock import patch
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
spec = importlib.util.spec_from_file_location('bridge', Path(__file__).resolve().parents[1] / 'bridge.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
class Handler(BaseHTTPRequestHandler):
def log_message(self, *_): pass
def handle_request(self):
length = int(self.headers.get('Content-Length', 0))
data = self.rfile.read(length)
self.server.requests.append((self.command, self.path, dict(self.headers), data))
if self.path == '/reject':
status, result = 401, {'error': 'Unauthorized'}
elif self.command == 'PUT':
status, result = 200, {'file': self.path.split('/')[-1], 'sha256': self.headers['X-SHA256']}
elif self.path == '/command':
status, result = 202, {'id': 'test-job'}
elif self.path == '/jobs/test-job':
status, result = 200, self.server.job_result
else:
status, result = 200, {'state': 'bound'}
body = json.dumps(result).encode()
self.send_response(status)
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
do_GET = do_POST = do_PUT = handle_request
class ClientTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
cls.server.requests = []
threading.Thread(target=cls.server.serve_forever, daemon=True).start()
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
cls.server.server_close()
def setUp(self):
self.client = module.Bridge(port=self.server.server_port)
self.client.token = 'test-token'
self.server.requests.clear()
def test_unicode_message_survives_http(self):
self.server.job_result = {'state': 'success', 'result': {'code': 200}}
self.client.command({'command': 'message', 'message': 'S2 真机 🙂'})
method, path, headers, body = self.server.requests[0]
self.assertEqual(json.loads(body)['message'], 'S2 真机 🙂')
self.assertEqual(headers['Authorization'], 'Bearer test-token')
self.assertEqual((method, path), ('POST', '/command'))
def test_watch_failure_is_preserved(self):
self.server.job_result = {'state': 'failed', 'result': {'code': 5005, 'msg': 'invalid package'}}
result = self.client.command({'command': 'install', 'file': 'test.pkg'})
self.assertEqual(result['state'], 'failed')
self.assertEqual(result['result']['code'], 5005)
def test_timeout_keeps_job_for_inspection(self):
result = self.client.command({'command': 'info'}, timeout=0)
self.assertEqual(result['state'], 'timeout')
self.assertEqual(result['id'], 'test-job')
def test_binary_upload_hash_and_safe_name(self):
with tempfile.TemporaryDirectory() as tmp:
file = Path(tmp) / '中文 package.pkg'
data = bytes(range(256)) * 32
file.write_bytes(data)
name = self.client.upload(file)
request = self.server.requests[0]
self.assertEqual(request[3], data)
self.assertRegex(name, r'^[a-f0-9]{24}\.pkg$')
self.assertEqual(request[2]['X-Sha256'], module.hashlib.sha256(data).hexdigest())
def test_rejected_request_does_not_leak_token(self):
with self.assertRaises(RuntimeError) as ctx:
self.client.request('GET', '/reject')
self.assertIn('401', str(ctx.exception))
self.assertNotIn('test-token', str(ctx.exception))
def test_empty_upload_rejected_before_network(self):
with tempfile.TemporaryDirectory() as tmp:
file = Path(tmp) / 'empty.pkg'
file.touch()
with self.assertRaises(ValueError): self.client.upload(file)
self.assertEqual(self.server.requests, [])
def test_install_supplies_sibling_png_and_pkg_to_one_job(self):
self.server.job_result = {'state': 'success', 'result': {'code': 200}}
with tempfile.TemporaryDirectory() as tmp:
package = Path(tmp) / 'bundle.pkg'; package.write_bytes(b'CR-test')
(Path(tmp) / 'icon.png').write_bytes(b'\x89PNG\r\n\x1a\n' + b'fixture')
result = self.client.install(package)
posts = [json.loads(row[3]) for row in self.server.requests if row[0] == 'POST']
self.assertEqual(len(posts), 1)
self.assertEqual(posts[0]['command'], 'install')
self.assertIn('iconFile', posts[0])
self.assertNotEqual(posts[0]['file'], posts[0]['iconFile'])
self.assertEqual(result['state'], 'success')
def test_renamed_pgf_icon_is_rejected_before_network(self):
with tempfile.TemporaryDirectory() as tmp:
package = Path(tmp) / 'bundle.pkg'; package.write_bytes(b'CR-test')
(Path(tmp) / 'icon.png').write_bytes(b'PGF\x00renamed')
with self.assertRaises(ValueError): self.client.install(package)
self.assertEqual(self.server.requests, [])
def test_wait_link_allows_health_to_reconnect(self):
offline = {'state': 'success', 'result': {'connected': False}}
online = {'state': 'success', 'result': {'connected': True}}
with patch.object(self.client, 'command', side_effect=[offline, online]) as command, patch.object(module.time, 'sleep'):
self.assertEqual(self.client.wait_link(5), online)
self.assertEqual(command.call_count, 2)
self.assertTrue(all(call.args[0] == {'command': 'link'} for call in command.call_args_list))
def test_wait_link_does_not_repeat_failed_calls(self):
failed = {'state': 'failed', 'result': 'disconnected'}
with patch.object(self.client, 'command', return_value=failed) as command:
self.assertEqual(self.client.wait_link(5), failed)
command.assert_called_once()
if __name__ == '__main__': unittest.main()