From ad84bf187de159a9919d074f70698dbb27b24035 Mon Sep 17 00:00:00 2001 From: Jeena Date: Fri, 16 Jan 2026 22:55:34 +0000 Subject: [PATCH] Document Python dependency and make integration tests conditional - Update README.md to mention Python 3 requirement for integration tests - Make rate limiting test skip gracefully if Python 3 is not available - Move and rename test helper script to tests/gemini_test_client.py - Update test to use new script path - Improve test documentation and error handling --- README.md | 4 +- tests/gemini_test_client.py | 195 ++++++++++++++++++++++++++++++++++++ tests/rate_limiting.rs | 14 ++- 3 files changed, 211 insertions(+), 2 deletions(-) create mode 100755 tests/gemini_test_client.py diff --git a/README.md b/README.md index 753da1c..2d29cc0 100644 --- a/README.md +++ b/README.md @@ -85,4 +85,6 @@ Access with a Gemini client like Lagrange at `gemini://yourdomain.com/`. ## Testing -Run `cargo test` for unit tests. Fix warnings before commits. +Run `cargo test` for the full test suite, which includes integration tests that require Python 3. + +**Note**: Integration tests use Python 3 for Gemini protocol validation. If Python 3 is not available, integration tests will be skipped automatically. diff --git a/tests/gemini_test_client.py b/tests/gemini_test_client.py new file mode 100755 index 0000000..b9b3975 --- /dev/null +++ b/tests/gemini_test_client.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +""" +Gemini Test Client + +A simple Gemini protocol client for testing Gemini servers. +Used by integration tests to validate server behavior. + +Usage: + python3 tests/gemini_test_client.py --url gemini://example.com/ --timeout 10 +""" + +import argparse +import socket +import ssl +import time +import multiprocessing +from concurrent.futures import ProcessPoolExecutor, as_completed + +def parse_args(): + """Parse command line arguments""" + parser = argparse.ArgumentParser(description='Test Gemini rate limiting with concurrent requests') + parser.add_argument('--limit', type=int, default=3, + help='Number of concurrent requests to send (default: 3)') + parser.add_argument('--host', default='localhost', + help='Server host (default: localhost)') + parser.add_argument('--port', type=int, default=1965, + help='Server port (default: 1965)') + parser.add_argument('--delay', type=float, default=0.1, + help='Delay between request start and connection close (default: 0.1s)') + parser.add_argument('--timeout', type=float, default=5.0, + help='Socket timeout in seconds (default: 5.0)') + parser.add_argument('--url', default='gemini://localhost/big-file.mkv', + help='Gemini URL to request (default: gemini://localhost/big-file.mkv)') + + args = parser.parse_args() + + # Validation + if args.limit < 1: + parser.error("Limit must be at least 1") + if args.limit > 10000: + parser.error("Limit too high (max 10000 for safety)") + if args.delay < 0: + parser.error("Delay must be non-negative") + if args.timeout <= 0: + parser.error("Timeout must be positive") + + return args + +def send_gemini_request(host, port, url, delay, timeout): + """Send one Gemini request with proper error handling""" + try: + # Create SSL context + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + + # Connect with timeout + sock = socket.create_connection((host, port), timeout=timeout) + ssl_sock = context.wrap_socket(sock, server_hostname=host) + + # Send request + request = f"{url}\r\n".encode('utf-8') + ssl_sock.send(request) + + # Read response with timeout + ssl_sock.settimeout(timeout) + response = ssl_sock.recv(1024) + + if not response: + return "Error: Empty response" + + status = response.decode('utf-8', errors='ignore').split('\r\n')[0] + + # Keep connection alive briefly if requested + if delay > 0: + time.sleep(delay) + + ssl_sock.close() + return status + + except socket.timeout: + return "Error: Timeout" + except ConnectionRefusedError: + return "Error: Connection refused" + except Exception as e: + return f"Error: {e}" + +def main(): + """Run concurrent requests""" + args = parse_args() + + if args.limit == 1: + print("Testing single request (debug mode)...") + start_time = time.time() + result = send_gemini_request(args.host, args.port, args.url, args.delay, args.timeout) + end_time = time.time() + duration = end_time - start_time + print(f"Result: {result}") + print(".2f") + return + + print(f"Testing rate limiting with {args.limit} concurrent requests (using multiprocessing)...") + print(f"Server: {args.host}:{args.port}") + print(f"URL: {args.url}") + print(f"Delay: {args.delay}s, Timeout: {args.timeout}s") + print() + + start_time = time.time() + + # Use ProcessPoolExecutor for true parallelism (bypasses GIL) + results = [] + max_workers = min(args.limit, multiprocessing.cpu_count() * 4) # Limit workers to avoid system overload + + with ProcessPoolExecutor(max_workers=max_workers) as executor: + futures = [ + executor.submit(send_gemini_request, args.host, args.port, + args.url, args.delay, args.timeout) + for _ in range(args.limit) + ] + + for future in as_completed(futures): + results.append(future.result()) + + elapsed = time.time() - start_time + + # Analyze results + status_counts = {} + connection_refused = 0 + timeouts = 0 + other_errors = [] + + for result in results: + if "Connection refused" in result: + connection_refused += 1 + elif "Timeout" in result: + timeouts += 1 + elif result.startswith("Error"): + other_errors.append(result) + else: + status_counts[result] = status_counts.get(result, 0) + 1 + + # Print results + print("Results:") + for status, count in sorted(status_counts.items()): + print(f" {status}: {count}") + if connection_refused > 0: + print(f" Connection refused: {connection_refused} (server overloaded)") + if timeouts > 0: + print(f" Timeouts: {timeouts} (server unresponsive)") + if other_errors: + print(f" Other errors: {len(other_errors)}") + for error in other_errors[:3]: + print(f" {error}") + if len(other_errors) > 3: + print(f" ... and {len(other_errors) - 3} more") + + print() + print(".2f") + + # Success criteria for rate limiting + success_20 = status_counts.get("20 application/octet-stream", 0) + rate_limited_41 = status_counts.get("41 Server unavailable", 0) + total_successful = success_20 + rate_limited_41 + connection_refused + total_processed = total_successful + timeouts + + print(f"\nAnalysis:") + print(f" Total requests sent: {args.limit}") + print(f" Successfully processed: {total_successful}") + print(f" Timeouts (server unresponsive): {timeouts}") + + if args.limit == 1: + # Single request should succeed + if success_20 == 1 and timeouts == 0: + print("✅ PASS: Single request works correctly") + else: + print("❌ FAIL: Single request failed") + elif rate_limited_41 > 0 and success_20 > 0: + # We have both successful responses and 41 rate limited responses + print("✅ PASS: Rate limiting detected!") + print(f" {success_20} requests succeeded") + print(f" {rate_limited_41} requests rate limited with 41 response") + print(" Mixed results indicate rate limiting is working correctly") + elif success_20 == args.limit and timeouts == 0: + # All requests succeeded + print("⚠️ All requests succeeded - rate limiting may not be triggered") + print(" This could mean:") + print(" - Requests are not truly concurrent") + print(" - Processing is too fast for overlap") + print(" - Need longer delays or more concurrent requests") + else: + print("❓ UNCLEAR: Check server logs and test parameters") + print(" May need to adjust --limit, delays, or server configuration") + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/tests/rate_limiting.rs b/tests/rate_limiting.rs index 8236ffa..85a79ab 100644 --- a/tests/rate_limiting.rs +++ b/tests/rate_limiting.rs @@ -2,6 +2,10 @@ use std::process::Command; #[test] fn test_rate_limiting_with_concurrent_requests() { + if !python_available() { + println!("Skipping rate limiting test: Python 3 not available"); + return; + } // Create temp config with max_concurrent_requests = 1 let temp_dir = std::env::temp_dir(); let config_path = temp_dir.join("pollux_test_config.toml"); @@ -35,7 +39,7 @@ fn test_rate_limiting_with_concurrent_requests() { for _ in 0..5 { let handle = std::thread::spawn(|| { Command::new("python3") - .arg("tmp/test_rate_limit_python.py") + .arg("tests/gemini_test_client.py") .arg("--limit") .arg("1") .arg("--host") @@ -76,4 +80,12 @@ fn test_rate_limiting_with_concurrent_requests() { // Verify: 1 success, 4 rate limited assert_eq!(success_count, 1); assert_eq!(rate_limited_count, 4); +} + +fn python_available() -> bool { + std::process::Command::new("python3") + .arg("--version") + .output() + .map(|output| output.status.success()) + .unwrap_or(false) } \ No newline at end of file