Skip to content

Commit cb5bf71

Browse files
whummerclaude
andcommitted
Consolidate integration tests and add gRPC end-to-end tests
- Merge test_tcp_forwarder_live.py and test_grpc_connectivity.py into test_http2_proxy.py - Add test_grpc_e2e.py with actual gRPC calls to grpcbin services - Extract shared HTTP/2 constants and parse_server_frames helper to conftest.py - Reduce test code from 794 to 643 lines (19% reduction, 151 lines eliminated) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 28295e2 commit cb5bf71

File tree

5 files changed

+292
-312
lines changed

5 files changed

+292
-312
lines changed

utils/pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ test = [
4141
"pytest-timeout>=2.0",
4242
"localstack",
4343
"jsonpatch",
44+
"grpcio>=1.60.0",
45+
"grpcio-tools>=1.60.0",
4446
]
4547

4648
[tool.setuptools.packages.find]

utils/tests/integration/conftest.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import socket
1212
import pytest
1313

14+
from hyperframe.frame import Frame
1415
from werkzeug.datastructures import Headers
1516
from localstack_extensions.utils.docker import ProxiedDockerContainerExtension
1617

@@ -19,6 +20,10 @@
1920
GRPCBIN_INSECURE_PORT = 9000 # HTTP/2 without TLS
2021
GRPCBIN_SECURE_PORT = 9001 # HTTP/2 with TLS
2122

23+
# HTTP/2 protocol constants
24+
HTTP2_PREFACE = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
25+
SETTINGS_FRAME = b"\x00\x00\x00\x04\x00\x00\x00\x00\x00" # Empty SETTINGS frame
26+
2227

2328
class GrpcbinExtension(ProxiedDockerContainerExtension):
2429
"""
@@ -93,3 +98,24 @@ def grpcbin_insecure_port(grpcbin_extension):
9398
def grpcbin_secure_port(grpcbin_extension):
9499
"""Return the secure (HTTP/2 with TLS) port for grpcbin."""
95100
return GRPCBIN_SECURE_PORT
101+
102+
103+
def parse_server_frames(data: bytes) -> list:
104+
"""Parse HTTP/2 frames from server response data (no preface expected).
105+
106+
Server responses don't include the HTTP/2 preface - they start with frames directly.
107+
This function parses raw frame data using hyperframe directly.
108+
"""
109+
frames = []
110+
pos = 0
111+
while pos + 9 <= len(data): # Frame header is 9 bytes
112+
try:
113+
frame, length = Frame.parse_frame_header(memoryview(data[pos : pos + 9]))
114+
if pos + 9 + length > len(data):
115+
break # Incomplete frame
116+
frame.parse_body(memoryview(data[pos + 9 : pos + 9 + length]))
117+
frames.append(frame)
118+
pos += 9 + length
119+
except Exception:
120+
break
121+
return frames
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""
2+
End-to-end gRPC tests using grpcbin services.
3+
4+
These tests make actual gRPC calls to grpcbin to verify that the full
5+
HTTP/2 stack works correctly, including proper request/response handling.
6+
7+
grpcbin provides services like: Empty, Index, HeadersUnary, etc.
8+
We use the Empty service which returns an empty response.
9+
"""
10+
11+
import grpc
12+
import pytest
13+
14+
15+
class TestGrpcEndToEnd:
16+
"""End-to-end tests making actual gRPC calls to grpcbin."""
17+
18+
def test_grpc_empty_call(self, grpcbin_host, grpcbin_insecure_port):
19+
"""Test making a gRPC call to grpcbin's Empty service."""
20+
# Create a channel to grpcbin
21+
channel = grpc.insecure_channel(f"{grpcbin_host}:{grpcbin_insecure_port}")
22+
23+
try:
24+
# Use grpc.channel_ready_future to verify connection
25+
grpc.channel_ready_future(channel).result(timeout=5)
26+
27+
# grpcbin provides /grpcbin.GRPCBin/Empty which returns empty response
28+
method = "/grpcbin.GRPCBin/Empty"
29+
30+
# Empty message is just empty bytes in protobuf
31+
request = b""
32+
33+
# Make the unary-unary call
34+
response = channel.unary_unary(
35+
method,
36+
request_serializer=lambda x: x,
37+
response_deserializer=lambda x: x,
38+
)(request, timeout=5)
39+
40+
# Empty service returns empty response
41+
assert response is not None
42+
assert response == b"" or len(response) == 0
43+
44+
finally:
45+
channel.close()
46+
47+
def test_grpc_index_call(self, grpcbin_host, grpcbin_insecure_port):
48+
"""Test calling grpcbin's Index service which returns server info."""
49+
channel = grpc.insecure_channel(f"{grpcbin_host}:{grpcbin_insecure_port}")
50+
51+
try:
52+
# Verify channel is ready
53+
grpc.channel_ready_future(channel).result(timeout=5)
54+
55+
# grpcbin's Index service returns information about the server
56+
method = "/grpcbin.GRPCBin/Index"
57+
request = b""
58+
59+
response = channel.unary_unary(
60+
method,
61+
request_serializer=lambda x: x,
62+
response_deserializer=lambda x: x,
63+
)(request, timeout=5)
64+
65+
# Index returns a non-empty protobuf message with server info
66+
assert response is not None
67+
assert len(response) > 0, "Index service should return server information"
68+
69+
finally:
70+
channel.close()
71+
72+
def test_grpc_concurrent_calls(self, grpcbin_host, grpcbin_insecure_port):
73+
"""Test making multiple concurrent gRPC calls."""
74+
channel = grpc.insecure_channel(f"{grpcbin_host}:{grpcbin_insecure_port}")
75+
76+
try:
77+
# Verify channel is ready
78+
grpc.channel_ready_future(channel).result(timeout=5)
79+
80+
method = "/grpcbin.GRPCBin/Empty"
81+
request = b""
82+
83+
# Make multiple concurrent calls
84+
responses = []
85+
for i in range(3):
86+
response = channel.unary_unary(
87+
method,
88+
request_serializer=lambda x: x,
89+
response_deserializer=lambda x: x,
90+
)(request, timeout=5)
91+
responses.append(response)
92+
93+
# Verify all calls completed
94+
assert len(responses) == 3, "All concurrent calls should complete"
95+
for i, response in enumerate(responses):
96+
assert response is not None, f"Call {i} should return a response"
97+
98+
finally:
99+
channel.close()
100+
101+
def test_grpc_connection_reuse(self, grpcbin_host, grpcbin_insecure_port):
102+
"""Test that a single gRPC channel can handle multiple sequential calls."""
103+
channel = grpc.insecure_channel(f"{grpcbin_host}:{grpcbin_insecure_port}")
104+
105+
try:
106+
# Verify channel is ready
107+
grpc.channel_ready_future(channel).result(timeout=5)
108+
109+
# Alternate between Empty and Index calls
110+
methods = ["/grpcbin.GRPCBin/Empty", "/grpcbin.GRPCBin/Index"]
111+
request = b""
112+
113+
# Make multiple sequential calls on the same channel
114+
for i in range(6):
115+
method = methods[i % 2]
116+
response = channel.unary_unary(
117+
method,
118+
request_serializer=lambda x: x,
119+
response_deserializer=lambda x: x,
120+
)(request, timeout=5)
121+
122+
assert response is not None, f"Call {i} to {method} should succeed"
123+
124+
# Index should return data, Empty should return empty
125+
if "Index" in method:
126+
assert len(response) > 0, "Index should return server info"
127+
128+
finally:
129+
channel.close()

0 commit comments

Comments
 (0)