2026-09-20 12:18:52 +08:00
|
|
|
import http.client
|
|
|
|
|
import json
|
|
|
|
|
import sys
|
|
|
|
|
import tempfile
|
|
|
|
|
import threading
|
2026-09-20 13:36:48 +08:00
|
|
|
import socket
|
|
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
|
from unittest.mock import patch
|
2026-09-20 12:18:52 +08:00
|
|
|
import unittest
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'coordinator'))
|
|
|
|
|
from robot_bt_coordinator.service import Coordinator
|
|
|
|
|
from robot_bt_coordinator.backends import ManualBackend
|
|
|
|
|
from robot_bt_coordinator.http_api import make_server
|
|
|
|
|
|
|
|
|
|
class HttpTest(unittest.TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.tmp=tempfile.TemporaryDirectory();self.c=Coordinator(str(Path(self.tmp.name)/'db'),ManualBackend(),{'robot_01'})
|
|
|
|
|
self.server=make_server(self.c,'127.0.0.1',0,'test-secret','operator-secret')
|
|
|
|
|
self.thread=threading.Thread(target=self.server.serve_forever,daemon=True);self.thread.start()
|
|
|
|
|
def tearDown(self):
|
|
|
|
|
self.server.shutdown();self.server.server_close();self.thread.join();self.c.close();self.tmp.cleanup()
|
|
|
|
|
def request(self,method,path,body=None,token='test-secret'):
|
|
|
|
|
conn=http.client.HTTPConnection('127.0.0.1',self.server.server_port,timeout=3)
|
|
|
|
|
headers={'Authorization':'Bearer '+token,'Content-Type':'application/json'}
|
|
|
|
|
conn.request(method,path,body,headers);r=conn.getresponse();result=(r.status,json.loads(r.read()));conn.close();return result
|
|
|
|
|
def test_auth_required_and_health_allowed(self):
|
|
|
|
|
self.assertEqual(self.request('GET','/v1/capabilities',token='wrong')[0],401)
|
|
|
|
|
self.assertEqual(self.request('GET','/healthz',token='wrong')[0],200)
|
|
|
|
|
def test_submit_get_cancel_and_event_cursor(self):
|
|
|
|
|
req=dict(client_request_id='http1',robot_id='robot_01',instruction='move water',known_info={})
|
|
|
|
|
code,t=self.request('POST','/v1/tasks',json.dumps(req));self.assertEqual(code,202)
|
|
|
|
|
tid=t['task_id'];self.assertEqual(self.request('GET','/v1/tasks/'+tid)[1]['status'],'QUEUED')
|
|
|
|
|
self.assertEqual(self.request('POST',f'/v1/tasks/{tid}/cancel','{}')[1]['status'],'CANCELED')
|
|
|
|
|
self.assertEqual(self.request('GET',f'/v1/tasks/{tid}/events?after=0')[0],200)
|
|
|
|
|
def test_duplicate_json_keys_nonfinite_and_unknown_endpoint_rejected(self):
|
|
|
|
|
for payload in ['{"robot_id":"a","robot_id":"b"}','{"quantity":NaN}']:
|
|
|
|
|
self.assertEqual(self.request('POST','/v1/tasks',payload)[0],400)
|
|
|
|
|
self.assertEqual(self.request('GET','/not-found')[0],404)
|
|
|
|
|
|
2026-09-20 13:36:48 +08:00
|
|
|
def test_listener_can_queue_ten_fresh_connections_before_accept(self):
|
|
|
|
|
# Model a burst while the accept loop is busy. This is a capacity check,
|
|
|
|
|
# not a throughput or latency assertion on the host running the suite.
|
|
|
|
|
listener=make_server(self.c,'127.0.0.1',0,'test-secret')
|
|
|
|
|
clients=[]
|
|
|
|
|
try:
|
|
|
|
|
for _ in range(10):
|
|
|
|
|
try:
|
|
|
|
|
clients.append(socket.create_connection(('127.0.0.1',listener.server_port),timeout=2))
|
|
|
|
|
except OSError as error:
|
|
|
|
|
self.fail('listener could not queue ten clients: '+str(error))
|
|
|
|
|
finally:
|
|
|
|
|
for client in clients:client.close()
|
|
|
|
|
listener.server_close()
|
|
|
|
|
|
|
|
|
|
def test_ten_concurrent_submissions_are_independently_accepted_and_deduplicated(self):
|
|
|
|
|
gate=threading.Barrier(10)
|
|
|
|
|
def submit(index):
|
|
|
|
|
body=json.dumps(dict(client_request_id='burst-'+str(index),robot_id='robot_01',instruction='move water',known_info={}))
|
|
|
|
|
gate.wait(timeout=10)
|
|
|
|
|
return self.request('POST','/v1/tasks',body)
|
|
|
|
|
with ThreadPoolExecutor(max_workers=10) as pool:
|
|
|
|
|
results=list(pool.map(submit,range(10)))
|
|
|
|
|
self.assertEqual([code for code,_ in results],[202]*10)
|
|
|
|
|
self.assertEqual(len({task['task_id'] for _,task in results}),10)
|
|
|
|
|
self.assertTrue(all(task['status']=='QUEUED' for _,task in results))
|
|
|
|
|
with ThreadPoolExecutor(max_workers=10) as pool:
|
|
|
|
|
repeats=list(pool.map(submit,range(10)))
|
|
|
|
|
self.assertEqual([task['task_id'] for _,task in repeats],[task['task_id'] for _,task in results])
|
|
|
|
|
self.assertTrue(all(code==202 and task['deduplicated'] for code,task in repeats))
|
|
|
|
|
self.assertEqual(len(self.c.store.all()),10)
|
|
|
|
|
|
|
|
|
|
def test_recovery_backend_failure_is_sanitized_service_unavailable(self):
|
|
|
|
|
with patch.object(self.c,'intervene',side_effect=RuntimeError('private-token-123 from ROS')):
|
|
|
|
|
code,body=self.request('POST','/v1/tasks/task/interventions','{}',token='operator-secret')
|
|
|
|
|
self.assertEqual(code,503);self.assertEqual(body['error_code'],'RECONCILIATION_UNAVAILABLE')
|
|
|
|
|
self.assertNotIn('private-token',json.dumps(body))
|
|
|
|
|
|
|
|
|
|
def test_recovery_timeout_returns_json_instead_of_dropping_connection(self):
|
|
|
|
|
with patch.object(self.c,'intervene',side_effect=TimeoutError('private-token-123 outcome unknown')):
|
|
|
|
|
try:code,body=self.request('POST','/v1/tasks/task/interventions','{}',token='operator-secret')
|
|
|
|
|
except http.client.RemoteDisconnected:self.fail('recovery timeout dropped the HTTP connection without a JSON result')
|
|
|
|
|
self.assertEqual(code,504);self.assertEqual(body['error_code'],'RECONCILIATION_TIMEOUT')
|
|
|
|
|
self.assertNotIn('private-token',json.dumps(body))
|
|
|
|
|
|
|
|
|
|
def test_recovery_backend_is_not_called_with_ordinary_api_token(self):
|
|
|
|
|
with patch.object(self.c,'intervene') as reconcile:
|
|
|
|
|
self.assertEqual(self.request('POST','/v1/tasks/task/interventions','{}')[0],401)
|
|
|
|
|
reconcile.assert_not_called()
|
|
|
|
|
|
2026-09-20 12:18:52 +08:00
|
|
|
if __name__=='__main__':unittest.main()
|