272 lines
9.2 KiB
Python
272 lines
9.2 KiB
Python
"""Admin 登记 HTTP 契约测试,不访问真实网络。"""
|
|
|
|
import io
|
|
import json
|
|
import socket
|
|
import unittest
|
|
from http.client import RemoteDisconnected
|
|
from urllib.error import HTTPError, URLError
|
|
|
|
from src.admin_gateway import (
|
|
AdminGatewayError,
|
|
AndroidDeviceInfo,
|
|
ClaimCapabilities,
|
|
ClientInfo,
|
|
)
|
|
from src.http_admin_gateway import HttpAdminGateway
|
|
|
|
|
|
class FakeResponse:
|
|
def __init__(self, status: int, payload):
|
|
self.status = status
|
|
self._body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
|
|
def getcode(self):
|
|
return self.status
|
|
|
|
def read(self):
|
|
return self._body
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return False
|
|
|
|
|
|
class RecordingOpener:
|
|
def __init__(self, response):
|
|
self.response = response
|
|
self.request = None
|
|
self.timeout = None
|
|
|
|
def __call__(self, request, timeout):
|
|
self.request = request
|
|
self.timeout = timeout
|
|
if isinstance(self.response, Exception):
|
|
raise self.response
|
|
return self.response
|
|
|
|
|
|
class HttpAdminGatewayTest(unittest.TestCase):
|
|
def _capabilities(self, with_device=True):
|
|
device = (
|
|
AndroidDeviceInfo("192.168.0.173:5555") if with_device else None
|
|
)
|
|
return ClaimCapabilities(device=device)
|
|
|
|
def test_register_sends_confirmed_contract_and_parses_response(self):
|
|
opener = RecordingOpener(
|
|
FakeResponse(
|
|
200,
|
|
{
|
|
"registered": True,
|
|
"client_id": "CLIENT-001",
|
|
"registered_at": "2026-08-06T10:00:00Z",
|
|
},
|
|
)
|
|
)
|
|
gateway = HttpAdminGateway(
|
|
"http://127.0.0.1:8080/", "secret-token", 2.5, opener
|
|
)
|
|
|
|
receipt = gateway.register_client(
|
|
ClientInfo("CLIENT-001", "办公室电脑"), self._capabilities()
|
|
)
|
|
|
|
self.assertTrue(receipt.registered)
|
|
self.assertEqual(opener.request.get_method(), "PUT")
|
|
self.assertEqual(
|
|
opener.request.full_url,
|
|
"http://127.0.0.1:8080/api/v1/client/registration",
|
|
)
|
|
headers = {
|
|
key.lower(): value for key, value in opener.request.header_items()
|
|
}
|
|
self.assertEqual(headers["x-client-id"], "CLIENT-001")
|
|
self.assertTrue(headers["x-request-id"])
|
|
self.assertEqual(headers["authorization"], "Bearer secret-token")
|
|
body = json.loads(opener.request.data.decode("utf-8"))
|
|
self.assertEqual(body["client"]["name"], "办公室电脑")
|
|
self.assertEqual(body["supported_types"], ["collect", "purchase"])
|
|
self.assertEqual(body["device"]["platform"], "android")
|
|
self.assertEqual(body["capabilities"]["purchase_mode"], "dry_run")
|
|
self.assertEqual(opener.timeout, 2.5)
|
|
|
|
def test_optional_device_is_omitted(self):
|
|
opener = RecordingOpener(
|
|
FakeResponse(
|
|
200,
|
|
{
|
|
"registered": True,
|
|
"client_id": "CLIENT-001",
|
|
"registered_at": "2026-08-06T10:00:00Z",
|
|
},
|
|
)
|
|
)
|
|
gateway = HttpAdminGateway(opener=opener)
|
|
|
|
gateway.register_client(
|
|
ClientInfo("CLIENT-001"), self._capabilities(False)
|
|
)
|
|
|
|
body = json.loads(opener.request.data.decode("utf-8"))
|
|
self.assertNotIn("device", body)
|
|
self.assertNotIn("authorization", {
|
|
key.lower(): value for key, value in opener.request.header_items()
|
|
})
|
|
|
|
def test_claim_maps_real_admin_payload_and_only_reports_collect(self):
|
|
opener = RecordingOpener(
|
|
FakeResponse(
|
|
200,
|
|
{
|
|
"task": {
|
|
"id": "COL-8020a8729f111c15",
|
|
"type": "collect",
|
|
"version": 1,
|
|
"priority": 0,
|
|
"payload": {
|
|
"goods_id": "737116531267",
|
|
"goods_url": (
|
|
"https://mobile.yangkeduo.com/goods.html"
|
|
"?goods_id=737116531267"
|
|
),
|
|
},
|
|
"created_at": "2026-08-07T03:19:49Z",
|
|
"updated_at": "2026-08-07T03:19:49Z",
|
|
}
|
|
},
|
|
)
|
|
)
|
|
gateway = HttpAdminGateway(opener=opener)
|
|
|
|
task = gateway.claim_next(
|
|
ClientInfo("CLIENT-001", "办公室电脑"),
|
|
self._capabilities(),
|
|
)
|
|
|
|
self.assertIsNotNone(task)
|
|
self.assertEqual(task.task_id, "COL-8020a8729f111c15")
|
|
self.assertEqual(task.task_type.value, "collect")
|
|
self.assertEqual(task.payload["goods_id"], "737116531267")
|
|
self.assertEqual(opener.request.get_method(), "POST")
|
|
self.assertEqual(
|
|
opener.request.full_url,
|
|
"http://127.0.0.1:8080/api/v1/client/tasks/claim",
|
|
)
|
|
headers = {
|
|
key.lower(): value for key, value in opener.request.header_items()
|
|
}
|
|
self.assertEqual(headers["x-client-id"], "CLIENT-001")
|
|
self.assertTrue(headers["x-request-id"])
|
|
self.assertIn("application/json", headers["content-type"])
|
|
body = json.loads(opener.request.data.decode("utf-8"))
|
|
self.assertEqual(body["supported_types"], ["collect"])
|
|
self.assertEqual(body["capabilities"]["purchase_mode"], "dry_run")
|
|
|
|
def test_claim_204_returns_none(self):
|
|
gateway = HttpAdminGateway(opener=RecordingOpener(FakeResponse(204, {})))
|
|
|
|
task = gateway.claim_next(
|
|
ClientInfo("CLIENT-001"),
|
|
self._capabilities(),
|
|
)
|
|
|
|
self.assertIsNone(task)
|
|
|
|
def test_claim_invalid_task_fields_include_request_id(self):
|
|
gateway = HttpAdminGateway(
|
|
opener=RecordingOpener(FakeResponse(200, {"task": {"id": None}}))
|
|
)
|
|
|
|
with self.assertRaises(AdminGatewayError) as context:
|
|
gateway.claim_next(
|
|
ClientInfo("CLIENT-001"),
|
|
self._capabilities(),
|
|
)
|
|
|
|
self.assertEqual(context.exception.code, "ADMIN_INVALID_RESPONSE")
|
|
self.assertTrue(context.exception.request_id)
|
|
|
|
def test_claim_http_error_keeps_server_request_id(self):
|
|
error_body = json.dumps(
|
|
{
|
|
"error": {
|
|
"code": "TASK_CLAIM_FAILED",
|
|
"message": "领取任务失败",
|
|
"retryable": True,
|
|
"request_id": "claim-request-id",
|
|
}
|
|
}
|
|
).encode("utf-8")
|
|
error = HTTPError(
|
|
"http://admin/api/v1/client/tasks/claim",
|
|
500,
|
|
"Internal Server Error",
|
|
{},
|
|
io.BytesIO(error_body),
|
|
)
|
|
gateway = HttpAdminGateway(opener=RecordingOpener(error))
|
|
|
|
with self.assertRaises(AdminGatewayError) as context:
|
|
gateway.claim_next(
|
|
ClientInfo("CLIENT-001"),
|
|
self._capabilities(),
|
|
)
|
|
|
|
self.assertEqual(context.exception.code, "TASK_CLAIM_FAILED")
|
|
self.assertEqual(context.exception.request_id, "claim-request-id")
|
|
self.assertTrue(context.exception.retryable)
|
|
|
|
def test_admin_error_preserves_code_retry_and_request_id(self):
|
|
error_body = json.dumps(
|
|
{
|
|
"error": {
|
|
"code": "INVALID_CLIENT_PROFILE",
|
|
"message": "资料无效",
|
|
"retryable": False,
|
|
"request_id": "server-request-id",
|
|
"details": {"field": "supported_types"},
|
|
}
|
|
}
|
|
).encode("utf-8")
|
|
error = HTTPError(
|
|
"http://admin/api/v1/client/registration",
|
|
422,
|
|
"Unprocessable Entity",
|
|
{},
|
|
io.BytesIO(error_body),
|
|
)
|
|
gateway = HttpAdminGateway(opener=RecordingOpener(error))
|
|
|
|
with self.assertRaises(AdminGatewayError) as context:
|
|
gateway.register_client(
|
|
ClientInfo("CLIENT-001"), self._capabilities(False)
|
|
)
|
|
|
|
self.assertEqual(context.exception.code, "INVALID_CLIENT_PROFILE")
|
|
self.assertFalse(context.exception.retryable)
|
|
self.assertEqual(context.exception.request_id, "server-request-id")
|
|
|
|
def test_timeout_and_connection_failure_are_retryable(self):
|
|
for exception, code in (
|
|
(URLError(socket.timeout()), "ADMIN_TIMEOUT"),
|
|
(URLError("connection refused"), "ADMIN_UNAVAILABLE"),
|
|
(RemoteDisconnected("closed"), "ADMIN_UNAVAILABLE"),
|
|
):
|
|
with self.subTest(code=code):
|
|
gateway = HttpAdminGateway(
|
|
opener=RecordingOpener(exception)
|
|
)
|
|
with self.assertRaises(AdminGatewayError) as context:
|
|
gateway.register_client(
|
|
ClientInfo("CLIENT-001"), self._capabilities(False)
|
|
)
|
|
self.assertEqual(context.exception.code, code)
|
|
self.assertTrue(context.exception.retryable)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|