1use anyhow::{Context, Result};
33use chrono::{DateTime, Utc};
34use reqwest::Client;
35use serde::{Deserialize, Serialize};
36use std::time::{Duration, Instant};
37
38#[derive(Debug, Clone)]
40pub struct ApiTestConfig {
41 pub server_url: String,
43 pub api_key: Option<String>,
45 pub timeout_secs: u64,
47 pub concurrent_requests: usize,
49 pub verbose: bool,
51}
52
53impl Default for ApiTestConfig {
54 fn default() -> Self {
55 Self {
56 server_url: "http://localhost:8080".to_string(),
57 api_key: None,
58 timeout_secs: 30,
59 concurrent_requests: 1,
60 verbose: false,
61 }
62 }
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct EndpointTestResult {
68 pub endpoint: String,
70 pub method: String,
72 pub success: bool,
74 pub status_code: u16,
76 pub response_time_ms: u64,
78 pub response_size_bytes: usize,
80 pub error: Option<String>,
82 pub timestamp: DateTime<Utc>,
84}
85
86impl EndpointTestResult {
87 fn success(
89 endpoint: String,
90 method: String,
91 status_code: u16,
92 response_time_ms: u64,
93 response_size_bytes: usize,
94 ) -> Self {
95 Self {
96 endpoint,
97 method,
98 success: true,
99 status_code,
100 response_time_ms,
101 response_size_bytes,
102 error: None,
103 timestamp: Utc::now(),
104 }
105 }
106
107 fn failure(endpoint: String, method: String, error: String) -> Self {
109 Self {
110 endpoint,
111 method,
112 success: false,
113 status_code: 0,
114 response_time_ms: 0,
115 response_size_bytes: 0,
116 error: Some(error),
117 timestamp: Utc::now(),
118 }
119 }
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct ApiTestReport {
125 pub server_url: String,
127 pub start_time: DateTime<Utc>,
129 pub end_time: DateTime<Utc>,
131 pub duration_secs: f64,
133 pub endpoint_results: Vec<EndpointTestResult>,
135 pub statistics: TestStatistics,
137 pub load_test: Option<LoadTestResults>,
139}
140
141impl ApiTestReport {
142 pub fn success_rate(&self) -> f64 {
144 if self.endpoint_results.is_empty() {
145 return 0.0;
146 }
147 let successful = self.endpoint_results.iter().filter(|r| r.success).count();
148 (successful as f64 / self.endpoint_results.len() as f64) * 100.0
149 }
150
151 pub fn to_markdown(&self) -> String {
153 let mut md = String::new();
154 md.push_str("# VoiRS API Test Report\n\n");
155 md.push_str(&format!("**Server**: {}\n", self.server_url));
156 md.push_str(&format!(
157 "**Date**: {}\n",
158 self.start_time.format("%Y-%m-%d %H:%M:%S UTC")
159 ));
160 md.push_str(&format!("**Duration**: {:.2}s\n\n", self.duration_secs));
161
162 md.push_str("## Summary\n\n");
163 md.push_str(&format!(
164 "- **Success Rate**: {:.1}%\n",
165 self.success_rate()
166 ));
167 md.push_str(&format!(
168 "- **Total Tests**: {}\n",
169 self.endpoint_results.len()
170 ));
171 md.push_str(&format!(
172 "- **Passed**: {}\n",
173 self.endpoint_results.iter().filter(|r| r.success).count()
174 ));
175 md.push_str(&format!(
176 "- **Failed**: {}\n",
177 self.endpoint_results.iter().filter(|r| !r.success).count()
178 ));
179 md.push_str(&format!(
180 "- **Avg Response Time**: {:.0}ms\n\n",
181 self.statistics.avg_response_time_ms
182 ));
183
184 md.push_str("## Endpoint Results\n\n");
185 md.push_str("| Endpoint | Method | Status | Response Time | Size | Result |\n");
186 md.push_str("|----------|--------|--------|---------------|------|--------|\n");
187
188 for result in &self.endpoint_results {
189 let status_emoji = if result.success { "ā
" } else { "ā" };
190 md.push_str(&format!(
191 "| {} | {} | {} | {}ms | {} bytes | {} |\n",
192 result.endpoint,
193 result.method,
194 result.status_code,
195 result.response_time_ms,
196 result.response_size_bytes,
197 status_emoji
198 ));
199 }
200
201 if let Some(load_test) = &self.load_test {
202 md.push_str("\n## Load Test Results\n\n");
203 md.push_str(&format!(
204 "- **Concurrent Requests**: {}\n",
205 load_test.concurrent_requests
206 ));
207 md.push_str(&format!(
208 "- **Total Requests**: {}\n",
209 load_test.total_requests
210 ));
211 md.push_str(&format!(
212 "- **Successful**: {}\n",
213 load_test.successful_requests
214 ));
215 md.push_str(&format!("- **Failed**: {}\n", load_test.failed_requests));
216 md.push_str(&format!(
217 "- **Avg Latency**: {:.0}ms\n",
218 load_test.avg_latency_ms
219 ));
220 md.push_str(&format!(
221 "- **Min Latency**: {}ms\n",
222 load_test.min_latency_ms
223 ));
224 md.push_str(&format!(
225 "- **Max Latency**: {}ms\n",
226 load_test.max_latency_ms
227 ));
228 md.push_str(&format!(
229 "- **Throughput**: {:.2} req/s\n",
230 load_test.requests_per_second
231 ));
232 }
233
234 md
235 }
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct TestStatistics {
241 pub total_tests: usize,
243 pub successful_tests: usize,
245 pub failed_tests: usize,
247 pub avg_response_time_ms: f64,
249 pub min_response_time_ms: u64,
251 pub max_response_time_ms: u64,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct LoadTestResults {
258 pub concurrent_requests: usize,
260 pub total_requests: usize,
262 pub successful_requests: usize,
264 pub failed_requests: usize,
266 pub avg_latency_ms: f64,
268 pub min_latency_ms: u64,
270 pub max_latency_ms: u64,
272 pub requests_per_second: f64,
274}
275
276pub struct ApiTester {
278 config: ApiTestConfig,
279 client: Client,
280}
281
282impl ApiTester {
283 pub fn new(config: ApiTestConfig) -> Result<Self> {
285 let client = Client::builder()
286 .timeout(Duration::from_secs(config.timeout_secs))
287 .build()
288 .context("Failed to create HTTP client")?;
289
290 Ok(Self { config, client })
291 }
292
293 pub async fn run_tests(&self) -> Result<ApiTestReport> {
295 let start_time = Utc::now();
296 let mut results = Vec::new();
297
298 println!("š Starting API tests for {}", self.config.server_url);
299 println!();
300
301 println!("Testing /api/v1/health...");
303 results.push(self.test_health().await);
304
305 println!("Testing /api/v1/voices...");
307 results.push(self.test_voices().await);
308
309 println!("Testing /api/v1/stats...");
311 results.push(self.test_stats().await);
312
313 println!("Testing /api/v1/synthesize...");
315 results.push(self.test_synthesize().await);
316
317 if self.config.api_key.is_some() {
319 println!("Testing /api/v1/auth/info...");
320 results.push(self.test_auth_info().await);
321 }
322
323 let end_time = Utc::now();
324 let duration_secs = (end_time - start_time).num_milliseconds() as f64 / 1000.0;
325
326 let statistics = self.calculate_statistics(&results);
328
329 let load_test = if self.config.concurrent_requests > 1 {
331 println!("\nš„ Running load test...");
332 Some(self.run_load_test().await?)
333 } else {
334 None
335 };
336
337 Ok(ApiTestReport {
338 server_url: self.config.server_url.clone(),
339 start_time,
340 end_time,
341 duration_secs,
342 endpoint_results: results,
343 statistics,
344 load_test,
345 })
346 }
347
348 async fn test_health(&self) -> EndpointTestResult {
350 let url = format!("{}/api/v1/health", self.config.server_url);
351 let start = Instant::now();
352
353 match self.client.get(&url).send().await {
354 Ok(response) => {
355 let elapsed = start.elapsed().as_millis() as u64;
356 let status = response.status().as_u16();
357 match response.bytes().await {
358 Ok(body) => EndpointTestResult::success(
359 "/api/v1/health".to_string(),
360 "GET".to_string(),
361 status,
362 elapsed,
363 body.len(),
364 ),
365 Err(e) => EndpointTestResult::failure(
366 "/api/v1/health".to_string(),
367 "GET".to_string(),
368 format!("Failed to read response body: {}", e),
369 ),
370 }
371 }
372 Err(e) => EndpointTestResult::failure(
373 "/api/v1/health".to_string(),
374 "GET".to_string(),
375 format!("Request failed: {}", e),
376 ),
377 }
378 }
379
380 async fn test_voices(&self) -> EndpointTestResult {
382 let url = format!("{}/api/v1/voices", self.config.server_url);
383 let start = Instant::now();
384
385 let mut request = self.client.get(&url);
386 if let Some(api_key) = &self.config.api_key {
387 request = request.header("Authorization", format!("Bearer {}", api_key));
388 }
389
390 match request.send().await {
391 Ok(response) => {
392 let elapsed = start.elapsed().as_millis() as u64;
393 let status = response.status().as_u16();
394 match response.bytes().await {
395 Ok(body) => EndpointTestResult::success(
396 "/api/v1/voices".to_string(),
397 "GET".to_string(),
398 status,
399 elapsed,
400 body.len(),
401 ),
402 Err(e) => EndpointTestResult::failure(
403 "/api/v1/voices".to_string(),
404 "GET".to_string(),
405 format!("Failed to read response body: {}", e),
406 ),
407 }
408 }
409 Err(e) => EndpointTestResult::failure(
410 "/api/v1/voices".to_string(),
411 "GET".to_string(),
412 format!("Request failed: {}", e),
413 ),
414 }
415 }
416
417 async fn test_stats(&self) -> EndpointTestResult {
419 let url = format!("{}/api/v1/stats", self.config.server_url);
420 let start = Instant::now();
421
422 let mut request = self.client.get(&url);
423 if let Some(api_key) = &self.config.api_key {
424 request = request.header("Authorization", format!("Bearer {}", api_key));
425 }
426
427 match request.send().await {
428 Ok(response) => {
429 let elapsed = start.elapsed().as_millis() as u64;
430 let status = response.status().as_u16();
431 match response.bytes().await {
432 Ok(body) => EndpointTestResult::success(
433 "/api/v1/stats".to_string(),
434 "GET".to_string(),
435 status,
436 elapsed,
437 body.len(),
438 ),
439 Err(e) => EndpointTestResult::failure(
440 "/api/v1/stats".to_string(),
441 "GET".to_string(),
442 format!("Failed to read response body: {}", e),
443 ),
444 }
445 }
446 Err(e) => EndpointTestResult::failure(
447 "/api/v1/stats".to_string(),
448 "GET".to_string(),
449 format!("Request failed: {}", e),
450 ),
451 }
452 }
453
454 async fn test_synthesize(&self) -> EndpointTestResult {
456 let url = format!("{}/api/v1/synthesize", self.config.server_url);
457 let start = Instant::now();
458
459 let body = serde_json::json!({
460 "text": "Hello, this is a test.",
461 "voice": "default",
462 });
463
464 let mut request = self.client.post(&url).json(&body);
465 if let Some(api_key) = &self.config.api_key {
466 request = request.header("Authorization", format!("Bearer {}", api_key));
467 }
468
469 match request.send().await {
470 Ok(response) => {
471 let elapsed = start.elapsed().as_millis() as u64;
472 let status = response.status().as_u16();
473 match response.bytes().await {
474 Ok(body_bytes) => EndpointTestResult::success(
475 "/api/v1/synthesize".to_string(),
476 "POST".to_string(),
477 status,
478 elapsed,
479 body_bytes.len(),
480 ),
481 Err(e) => EndpointTestResult::failure(
482 "/api/v1/synthesize".to_string(),
483 "POST".to_string(),
484 format!("Failed to read response body: {}", e),
485 ),
486 }
487 }
488 Err(e) => EndpointTestResult::failure(
489 "/api/v1/synthesize".to_string(),
490 "POST".to_string(),
491 format!("Request failed: {}", e),
492 ),
493 }
494 }
495
496 async fn test_auth_info(&self) -> EndpointTestResult {
498 let url = format!("{}/api/v1/auth/info", self.config.server_url);
499 let start = Instant::now();
500
501 let mut request = self.client.get(&url);
502 if let Some(api_key) = &self.config.api_key {
503 request = request.header("Authorization", format!("Bearer {}", api_key));
504 }
505
506 match request.send().await {
507 Ok(response) => {
508 let elapsed = start.elapsed().as_millis() as u64;
509 let status = response.status().as_u16();
510 match response.bytes().await {
511 Ok(body) => EndpointTestResult::success(
512 "/api/v1/auth/info".to_string(),
513 "GET".to_string(),
514 status,
515 elapsed,
516 body.len(),
517 ),
518 Err(e) => EndpointTestResult::failure(
519 "/api/v1/auth/info".to_string(),
520 "GET".to_string(),
521 format!("Failed to read response body: {}", e),
522 ),
523 }
524 }
525 Err(e) => EndpointTestResult::failure(
526 "/api/v1/auth/info".to_string(),
527 "GET".to_string(),
528 format!("Request failed: {}", e),
529 ),
530 }
531 }
532
533 fn calculate_statistics(&self, results: &[EndpointTestResult]) -> TestStatistics {
535 let total_tests = results.len();
536 let successful_tests = results.iter().filter(|r| r.success).count();
537 let failed_tests = total_tests - successful_tests;
538
539 let response_times: Vec<u64> = results
540 .iter()
541 .filter(|r| r.success)
542 .map(|r| r.response_time_ms)
543 .collect();
544
545 let avg_response_time_ms = if response_times.is_empty() {
546 0.0
547 } else {
548 response_times.iter().sum::<u64>() as f64 / response_times.len() as f64
549 };
550
551 let min_response_time_ms = response_times.iter().min().copied().unwrap_or(0);
552 let max_response_time_ms = response_times.iter().max().copied().unwrap_or(0);
553
554 TestStatistics {
555 total_tests,
556 successful_tests,
557 failed_tests,
558 avg_response_time_ms,
559 min_response_time_ms,
560 max_response_time_ms,
561 }
562 }
563
564 async fn run_load_test(&self) -> Result<LoadTestResults> {
566 let url = format!("{}/api/v1/health", self.config.server_url);
567 let concurrent = self.config.concurrent_requests;
568 let total_requests = concurrent * 10; let start = Instant::now();
571 let mut handles = Vec::new();
572
573 for _ in 0..concurrent {
574 let client = self.client.clone();
575 let url = url.clone();
576 let handle = tokio::spawn(async move {
577 let mut results = Vec::new();
578 for _ in 0..10 {
579 let req_start = Instant::now();
580 let result = client.get(&url).send().await;
581 let elapsed = req_start.elapsed().as_millis() as u64;
582 results.push((result.is_ok(), elapsed));
583 }
584 results
585 });
586 handles.push(handle);
587 }
588
589 let mut all_results = Vec::new();
590 for handle in handles {
591 let results = handle.await?;
592 all_results.extend(results);
593 }
594
595 let duration = start.elapsed();
596 let successful = all_results.iter().filter(|(ok, _)| *ok).count();
597 let failed = total_requests - successful;
598
599 let latencies: Vec<u64> = all_results.iter().map(|(_, latency)| *latency).collect();
600 let avg_latency_ms = latencies.iter().sum::<u64>() as f64 / latencies.len() as f64;
601 let min_latency_ms = latencies.iter().min().copied().unwrap_or(0);
602 let max_latency_ms = latencies.iter().max().copied().unwrap_or(0);
603 let requests_per_second = total_requests as f64 / duration.as_secs_f64();
604
605 Ok(LoadTestResults {
606 concurrent_requests: concurrent,
607 total_requests,
608 successful_requests: successful,
609 failed_requests: failed,
610 avg_latency_ms,
611 min_latency_ms,
612 max_latency_ms,
613 requests_per_second,
614 })
615 }
616}
617
618pub fn print_report(report: &ApiTestReport) {
620 println!("\n{}", "=".repeat(60));
621 println!(" VoiRS API Test Report");
622 println!("{}\n", "=".repeat(60));
623
624 println!("Server: {}", report.server_url);
625 println!("Duration: {:.2}s", report.duration_secs);
626 println!();
627
628 println!("Summary:");
629 println!(" Success Rate: {:.1}%", report.success_rate());
630 println!(" Total Tests: {}", report.endpoint_results.len());
631 println!(
632 " Passed: {}",
633 report.endpoint_results.iter().filter(|r| r.success).count()
634 );
635 println!(
636 " Failed: {}",
637 report
638 .endpoint_results
639 .iter()
640 .filter(|r| !r.success)
641 .count()
642 );
643 println!(
644 " Avg Response Time: {:.0}ms",
645 report.statistics.avg_response_time_ms
646 );
647 println!();
648
649 println!("Endpoint Results:");
650 for result in &report.endpoint_results {
651 let status = if result.success {
652 "ā
PASS"
653 } else {
654 "ā FAIL"
655 };
656 println!(
657 " {} {} {} - {} - {}ms",
658 status, result.method, result.endpoint, result.status_code, result.response_time_ms
659 );
660 if let Some(error) = &result.error {
661 println!(" Error: {}", error);
662 }
663 }
664
665 if let Some(load_test) = &report.load_test {
666 println!();
667 println!("Load Test Results:");
668 println!(" Concurrent Requests: {}", load_test.concurrent_requests);
669 println!(" Total Requests: {}", load_test.total_requests);
670 println!(" Successful: {}", load_test.successful_requests);
671 println!(" Failed: {}", load_test.failed_requests);
672 println!(" Avg Latency: {:.0}ms", load_test.avg_latency_ms);
673 println!(" Min Latency: {}ms", load_test.min_latency_ms);
674 println!(" Max Latency: {}ms", load_test.max_latency_ms);
675 println!(" Throughput: {:.2} req/s", load_test.requests_per_second);
676 }
677
678 println!("\n{}\n", "=".repeat(60));
679}
680
681pub async fn run_api_tests(
683 server_url: String,
684 api_key: Option<String>,
685 concurrent: Option<usize>,
686 report_path: Option<String>,
687 verbose: bool,
688) -> Result<()> {
689 let config = ApiTestConfig {
690 server_url,
691 api_key,
692 timeout_secs: 30,
693 concurrent_requests: concurrent.unwrap_or(1),
694 verbose,
695 };
696
697 let tester = ApiTester::new(config)?;
698 let report = tester.run_tests().await?;
699
700 print_report(&report);
702
703 if let Some(path) = report_path {
705 if path.ends_with(".json") {
706 let json = serde_json::to_string_pretty(&report)
707 .context("Failed to serialize report to JSON")?;
708 std::fs::write(&path, json).context("Failed to write JSON report")?;
709 println!("š JSON report saved to: {}", path);
710 } else if path.ends_with(".md") {
711 let markdown = report.to_markdown();
712 std::fs::write(&path, markdown).context("Failed to write Markdown report")?;
713 println!("š Markdown report saved to: {}", path);
714 } else {
715 anyhow::bail!("Report path must end with .json or .md");
716 }
717 }
718
719 if report.statistics.failed_tests > 0 {
721 anyhow::bail!("{} test(s) failed", report.statistics.failed_tests);
722 }
723
724 Ok(())
725}
726
727#[cfg(test)]
728mod tests {
729 use super::*;
730
731 #[test]
732 fn test_api_test_config_default() {
733 let config = ApiTestConfig::default();
734 assert_eq!(config.server_url, "http://localhost:8080");
735 assert_eq!(config.timeout_secs, 30);
736 assert_eq!(config.concurrent_requests, 1);
737 assert!(!config.verbose);
738 }
739
740 #[test]
741 fn test_endpoint_test_result_success() {
742 let result = EndpointTestResult::success(
743 "/api/v1/health".to_string(),
744 "GET".to_string(),
745 200,
746 150,
747 1024,
748 );
749
750 assert!(result.success);
751 assert_eq!(result.endpoint, "/api/v1/health");
752 assert_eq!(result.method, "GET");
753 assert_eq!(result.status_code, 200);
754 assert_eq!(result.response_time_ms, 150);
755 assert_eq!(result.response_size_bytes, 1024);
756 assert!(result.error.is_none());
757 }
758
759 #[test]
760 fn test_endpoint_test_result_failure() {
761 let result = EndpointTestResult::failure(
762 "/api/v1/health".to_string(),
763 "GET".to_string(),
764 "Connection refused".to_string(),
765 );
766
767 assert!(!result.success);
768 assert_eq!(result.endpoint, "/api/v1/health");
769 assert_eq!(result.method, "GET");
770 assert_eq!(result.error, Some("Connection refused".to_string()));
771 }
772
773 #[test]
774 fn test_api_test_report_success_rate() {
775 let results = vec![
776 EndpointTestResult::success(
777 "/api/v1/health".to_string(),
778 "GET".to_string(),
779 200,
780 100,
781 512,
782 ),
783 EndpointTestResult::success(
784 "/api/v1/voices".to_string(),
785 "GET".to_string(),
786 200,
787 150,
788 1024,
789 ),
790 EndpointTestResult::failure(
791 "/api/v1/synth".to_string(),
792 "POST".to_string(),
793 "Error".to_string(),
794 ),
795 ];
796
797 let report = ApiTestReport {
798 server_url: "http://localhost:8080".to_string(),
799 start_time: Utc::now(),
800 end_time: Utc::now(),
801 duration_secs: 1.5,
802 endpoint_results: results,
803 statistics: TestStatistics {
804 total_tests: 3,
805 successful_tests: 2,
806 failed_tests: 1,
807 avg_response_time_ms: 125.0,
808 min_response_time_ms: 100,
809 max_response_time_ms: 150,
810 },
811 load_test: None,
812 };
813
814 assert!((report.success_rate() - 66.666).abs() < 0.01);
815 }
816
817 #[test]
818 fn test_markdown_report_generation() {
819 let results = vec![EndpointTestResult::success(
820 "/api/v1/health".to_string(),
821 "GET".to_string(),
822 200,
823 100,
824 512,
825 )];
826
827 let report = ApiTestReport {
828 server_url: "http://localhost:8080".to_string(),
829 start_time: Utc::now(),
830 end_time: Utc::now(),
831 duration_secs: 0.5,
832 endpoint_results: results,
833 statistics: TestStatistics {
834 total_tests: 1,
835 successful_tests: 1,
836 failed_tests: 0,
837 avg_response_time_ms: 100.0,
838 min_response_time_ms: 100,
839 max_response_time_ms: 100,
840 },
841 load_test: None,
842 };
843
844 let markdown = report.to_markdown();
845 assert!(markdown.contains("# VoiRS API Test Report"));
846 assert!(markdown.contains("http://localhost:8080"));
847 assert!(markdown.contains("/api/v1/health"));
848 }
849}