"""Exercise HTTP framing and broker acknowledgement through real TCP sockets.""" import asyncio import json import unittest from concurrent.futures import Future from app.api.server import IngestionAPIServer class PendingProducer: def __init__(self): self.deliveries = [] self.produced = asyncio.Event() self.closed = False def produce(self, topic, key, value): future = Future() self.deliveries.append(future) self.produced.set() return future def is_healthy(self): return True def flush(self, timeout=5): return sum(not future.done() for future in self.deliveries) def close(self, timeout=5): self.closed = True class HTTPBoundaryTests(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.producer = PendingProducer() self.server = None self.writers = [] self.tasks = [] async def asyncTearDown(self): for future in self.producer.deliveries: if not future.done(): future.set_result(None) for writer in self.writers: writer.close() await writer.wait_closed() if self.server is not None: await asyncio.wait_for(self.server.stop(), timeout=2) for task in self.tasks: if not task.done(): task.cancel() await asyncio.gather(*self.tasks, return_exceptions=True) async def start_server(self, **options): self.server = IngestionAPIServer(host="127.0.0.1", port=0, producer=self.producer, **options) await self.server.start() self.port = self.server._server.sockets[0].getsockname()[1] async def send(self, request): reader, writer = await asyncio.open_connection("127.0.0.1", self.port) self.writers.append(writer) writer.write(request) await writer.drain() task = asyncio.create_task(reader.read()) self.tasks.append(task) return task def valid_request(self): body = json.dumps(dict(raw_id="http-boundary", device_id="123", caller_num="0019875551234", svc="4G_DATA", rat=6, up_bytes=100, down_bytes=200, lac="1", ci="2", epoch_start=1726070000, epoch_end=1726070300)).encode() return (b"POST /api/v1/cdr/raw HTTP/1.1\r\nHost: localhost\r\n" + f"Content-Length: {len(body)}\r\n\r\n".encode() + body) async def assert_response(self, task, status): response = await asyncio.wait_for(asyncio.shield(task), timeout=2) headers, body = response.split(b"\r\n\r\n", 1) self.assertEqual(int(headers.split(b" ")[1]), status, response) decoded = json.loads(body) if status >= 400: self.assertIn("error", decoded) return decoded async def test_malformed_framing_returns_structured_400(self): await self.start_server() headers = [b"Content-Length: invalid", b"Content-Length: -1", b"Content-Length: 2\r\nContent-Length: 3", b"Transfer-Encoding: chunked", b"Transfer-Encoding: chunked\r\nContent-Length: 2"] for header in headers: with self.subTest(header=header): task = await self.send(b"POST /api/v1/cdr/raw HTTP/1.1\r\n" + header + b"\r\n\r\n{}") await self.assert_response(task, 400) self.assertEqual(self.producer.deliveries, []) async def test_oversized_declared_body_is_rejected_before_reading(self): await self.start_server(max_body_bytes=32) task = await self.send(b"POST /api/v1/cdr/raw HTTP/1.1\r\nContent-Length: 33\r\n\r\n") await self.assert_response(task, 413) self.assertEqual(self.producer.deliveries, []) async def test_incomplete_body_times_out_with_structured_408(self): await self.start_server(read_timeout=0.03) task = await self.send(b"POST /api/v1/cdr/raw HTTP/1.1\r\nContent-Length: 20\r\n\r\n{") await self.assert_response(task, 408) self.assertEqual(self.producer.deliveries, []) async def test_response_waits_for_broker_acknowledgement(self): await self.start_server(delivery_timeout=1) task = await self.send(self.valid_request()) await asyncio.wait_for(self.producer.produced.wait(), timeout=1) await asyncio.sleep(0.03) self.assertFalse(task.done(), "HTTP accepted before broker acknowledgement") self.producer.deliveries[0].set_result(None) await self.assert_response(task, 202) async def test_timeout_holds_capacity_until_delivery_finishes(self): await self.start_server(queue_max_size=1, delivery_timeout=0.03) first = await self.send(self.valid_request()) await self.assert_response(first, 503) self.assertEqual(len(self.producer.deliveries), 1) self.assertFalse(self.producer.deliveries[0].cancelled()) await self.assert_response(await self.send(self.valid_request()), 429) self.producer.deliveries[0].set_result(None) await asyncio.sleep(0.03) self.server.delivery_timeout = 1.0 self.producer.produced.clear() third = await self.send(self.valid_request()) await asyncio.wait_for(self.producer.produced.wait(), timeout=1) self.assertEqual(len(self.producer.deliveries), 2) self.producer.deliveries[1].set_result(None) await self.assert_response(third, 202) async def test_shutdown_waits_for_outstanding_acknowledgement(self): await self.start_server(delivery_timeout=1) request = await self.send(self.valid_request()) await asyncio.wait_for(self.producer.produced.wait(), timeout=1) stop = asyncio.create_task(self.server.stop()) self.tasks.append(stop) await asyncio.sleep(0.03) self.assertFalse(stop.done(), "Shutdown discarded an outstanding publication") self.assertFalse(self.producer.closed) self.producer.deliveries[0].set_result(None) await asyncio.wait_for(stop, timeout=2) await self.assert_response(request, 202) self.assertTrue(self.producer.closed) self.server = None if __name__ == "__main__": unittest.main()