1use super::spec_driven::AnnotatedOperation;
22use reqwest::{Client, Method};
23use std::collections::BTreeMap;
24use std::time::Duration;
25
26#[derive(Debug, Clone)]
28pub struct SelfTestConfig {
29 pub target_url: String,
30 pub skip_tls_verify: bool,
31 pub timeout: Duration,
32 pub extra_headers: Vec<(String, String)>,
34 pub delay_between_requests: Duration,
36}
37
38impl Default for SelfTestConfig {
39 fn default() -> Self {
40 Self {
41 target_url: "http://localhost:3000".into(),
42 skip_tls_verify: false,
43 timeout: Duration::from_secs(15),
44 extra_headers: Vec::new(),
45 delay_between_requests: Duration::from_millis(0),
46 }
47 }
48}
49
50#[derive(Debug, Clone, serde::Serialize)]
52pub struct CaseOutcome {
53 pub label: String,
54 pub expected_4xx: bool,
55 pub actual_status: u16,
56 pub passed: bool,
59}
60
61#[derive(Debug, Clone, serde::Serialize)]
63pub struct OperationResult {
64 pub method: String,
65 pub path: String,
66 pub positive: Option<CaseOutcome>,
67 pub negatives: Vec<CaseOutcome>,
68}
69
70#[derive(Debug, Default, Clone, serde::Serialize)]
72pub struct SelfTestReport {
73 pub positive_pass: usize,
74 pub positive_fail: usize,
75 pub negative_caught: BTreeMap<String, usize>,
78 pub negative_missed: BTreeMap<String, usize>,
81 pub operations: Vec<OperationResult>,
82}
83
84impl SelfTestReport {
85 pub fn all_passed(&self) -> bool {
88 self.positive_fail == 0 && self.negative_missed.values().sum::<usize>() == 0
89 }
90
91 pub fn render_summary(&self) -> String {
95 let mut out = String::new();
96 out.push_str(&format!(
97 "Positives: {} pass / {} fail\n",
98 self.positive_pass, self.positive_fail
99 ));
100 let mut keys: Vec<&String> =
101 self.negative_caught.keys().chain(self.negative_missed.keys()).collect();
102 keys.sort();
103 keys.dedup();
104 for cat in keys {
105 let caught = self.negative_caught.get(cat).copied().unwrap_or(0);
106 let missed = self.negative_missed.get(cat).copied().unwrap_or(0);
107 let mark = if missed == 0 { "✓" } else { "⚠" };
108 out.push_str(&format!(
109 "Negatives [{}]: {} caught / {} missed {}\n",
110 cat, caught, missed, mark
111 ));
112 }
113 out
114 }
115}
116
117pub async fn run_self_test(
122 operations: &[AnnotatedOperation],
123 config: &SelfTestConfig,
124) -> Result<SelfTestReport, reqwest::Error> {
125 let mut builder = Client::builder().timeout(config.timeout);
126 if config.skip_tls_verify {
127 builder = builder.danger_accept_invalid_certs(true);
128 }
129 let client = builder.build()?;
130
131 let mut report = SelfTestReport::default();
132 for op in operations {
133 let result = test_operation(&client, config, op).await;
134 if let Some(p) = &result.positive {
135 if p.passed {
136 report.positive_pass += 1;
137 } else {
138 report.positive_fail += 1;
139 }
140 }
141 for neg in &result.negatives {
142 let cat = neg.label.split(':').next().unwrap_or("other").to_string();
143 if neg.passed {
144 *report.negative_caught.entry(cat).or_insert(0) += 1;
145 } else {
146 *report.negative_missed.entry(cat).or_insert(0) += 1;
147 }
148 }
149 report.operations.push(result);
150 if !config.delay_between_requests.is_zero() {
151 tokio::time::sleep(config.delay_between_requests).await;
152 }
153 }
154 Ok(report)
155}
156
157async fn test_operation(
158 client: &Client,
159 config: &SelfTestConfig,
160 op: &AnnotatedOperation,
161) -> OperationResult {
162 let url = build_url(&config.target_url, &op.path, &op.path_params);
163 let method = Method::from_bytes(op.method.to_uppercase().as_bytes()).unwrap_or(Method::GET);
164
165 let positive = send_case(
167 client,
168 config,
169 method.clone(),
170 &url,
171 "positive",
172 false,
173 op.sample_body.as_deref(),
174 op.query_params.clone(),
175 op.header_params.clone(),
176 )
177 .await;
178
179 let mut negatives = Vec::new();
181
182 if op.request_body_content_type.is_some() {
191 negatives.push(
192 send_case(
193 client,
194 config,
195 method.clone(),
196 &url,
197 "request-body:empty",
198 true,
199 Some("{}"),
200 op.query_params.clone(),
201 op.header_params.clone(),
202 )
203 .await,
204 );
205
206 negatives.push(
210 send_case(
211 client,
212 config,
213 method.clone(),
214 &url,
215 "request-body:wrong-type",
216 true,
217 Some("[]"),
218 op.query_params.clone(),
219 op.header_params.clone(),
220 )
221 .await,
222 );
223 }
224
225 if !op.path_params.is_empty() {
239 let mut url_with_placeholder = op.path.clone();
240 if let Some((first_name, _)) = op.path_params.first() {
241 for (name, value) in op.path_params.iter().skip(1) {
244 if !value.is_empty() {
245 url_with_placeholder =
246 url_with_placeholder.replace(&format!("{{{name}}}"), value);
247 }
248 }
249 url_with_placeholder =
253 url_with_placeholder.replace(&format!("{{{first_name}}}"), "self-test-invalid-id");
254 let target = config.target_url.trim_end_matches('/');
255 let bad_url = if url_with_placeholder.starts_with('/') {
256 format!("{}{}", target, url_with_placeholder)
257 } else {
258 format!("{}/{}", target, url_with_placeholder)
259 };
260 negatives.push(
261 send_case(
262 client,
263 config,
264 method.clone(),
265 &bad_url,
266 "parameters:bad-path-param",
267 true,
268 op.sample_body.as_deref(),
269 op.query_params.clone(),
270 op.header_params.clone(),
271 )
272 .await,
273 );
274 }
275 }
276
277 if !op.query_params.is_empty() {
279 let mut q = op.query_params.clone();
280 q.remove(0);
281 negatives.push(
282 send_case(
283 client,
284 config,
285 method.clone(),
286 &url,
287 "parameters:missing-query",
288 true,
289 op.sample_body.as_deref(),
290 q,
291 op.header_params.clone(),
292 )
293 .await,
294 );
295 }
296
297 if !op.header_params.is_empty() {
299 let mut h = op.header_params.clone();
300 h.remove(0);
301 negatives.push(
302 send_case(
303 client,
304 config,
305 method.clone(),
306 &url,
307 "parameters:missing-header",
308 true,
309 op.sample_body.as_deref(),
310 op.query_params.clone(),
311 h,
312 )
313 .await,
314 );
315 }
316
317 OperationResult {
318 method: op.method.clone(),
319 path: op.path.clone(),
320 positive: Some(positive),
321 negatives,
322 }
323}
324
325#[allow(clippy::too_many_arguments)]
326async fn send_case(
327 client: &Client,
328 config: &SelfTestConfig,
329 method: Method,
330 url: &str,
331 label: &str,
332 expected_4xx: bool,
333 body: Option<&str>,
334 query: Vec<(String, String)>,
335 headers: Vec<(String, String)>,
336) -> CaseOutcome {
337 let mut req = client.request(method, url);
338 for (k, v) in &query {
339 req = req.query(&[(k.as_str(), v.as_str())]);
340 }
341 for (k, v) in &headers {
342 req = req.header(k, v);
343 }
344 for (k, v) in &config.extra_headers {
345 req = req.header(k, v);
346 }
347 if let Some(b) = body {
348 req = req
349 .header(reqwest::header::CONTENT_TYPE, "application/json")
350 .body(b.to_string());
351 }
352
353 let actual_status = match req.send().await {
354 Ok(resp) => resp.status().as_u16(),
355 Err(e) if e.is_timeout() => 0,
356 Err(_) => 0,
357 };
358
359 let passed = if expected_4xx {
360 (400..500).contains(&actual_status)
361 } else {
362 (200..400).contains(&actual_status)
363 };
364
365 CaseOutcome {
366 label: label.to_string(),
367 expected_4xx,
368 actual_status,
369 passed,
370 }
371}
372
373fn build_url(target: &str, path_template: &str, path_params: &[(String, String)]) -> String {
379 let mut url = path_template.to_string();
380 for (name, value) in path_params {
381 let placeholder = format!("{{{}}}", name);
382 if !value.is_empty() {
383 url = url.replace(&placeholder, value);
384 }
385 }
386 let target = target.trim_end_matches('/');
387 if url.starts_with('/') {
388 format!("{}{}", target, url)
389 } else {
390 format!("{}/{}", target, url)
391 }
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397
398 fn op(
399 method: &str,
400 path: &str,
401 body: Option<&str>,
402 query: Vec<(&str, &str)>,
403 headers: Vec<(&str, &str)>,
404 path_params: Vec<(&str, &str)>,
405 ) -> AnnotatedOperation {
406 AnnotatedOperation {
407 method: method.into(),
408 path: path.into(),
409 features: Vec::new(),
410 request_body_content_type: body.map(|_| "application/json".into()),
411 sample_body: body.map(|s| s.to_string()),
412 query_params: query.into_iter().map(|(a, b)| (a.into(), b.into())).collect(),
413 header_params: headers.into_iter().map(|(a, b)| (a.into(), b.into())).collect(),
414 path_params: path_params.into_iter().map(|(a, b)| (a.into(), b.into())).collect(),
415 response_schema: None,
416 security_schemes: Vec::new(),
417 }
418 }
419
420 #[test]
421 fn build_url_substitutes_path_params() {
422 let url = build_url(
423 "https://api.test/",
424 "/users/{id}/posts/{pid}",
425 &[("id".into(), "42".into()), ("pid".into(), "7".into())],
426 );
427 assert_eq!(url, "https://api.test/users/42/posts/7");
428 }
429
430 #[test]
431 fn build_url_keeps_placeholders_when_no_sample() {
432 let url = build_url("https://api.test", "/users/{id}", &[]);
433 assert_eq!(url, "https://api.test/users/{id}");
434 }
435
436 #[test]
437 fn report_summary_calls_out_misses() {
438 let r = SelfTestReport {
439 positive_pass: 3,
440 positive_fail: 0,
441 negative_caught: BTreeMap::from([("request-body".into(), 2)]),
442 negative_missed: BTreeMap::from([("request-body".into(), 1)]),
443 operations: Vec::new(),
444 };
445 let summary = r.render_summary();
446 assert!(summary.contains("Positives: 3 pass / 0 fail"));
447 assert!(summary.contains("Negatives [request-body]: 2 caught / 1 missed"));
448 assert!(summary.contains("⚠"));
449 assert!(!r.all_passed());
450 }
451
452 #[test]
453 fn report_all_passed_when_no_miss() {
454 let r = SelfTestReport {
455 positive_pass: 5,
456 positive_fail: 0,
457 negative_caught: BTreeMap::from([("parameters".into(), 3)]),
458 negative_missed: BTreeMap::new(),
459 operations: Vec::new(),
460 };
461 assert!(r.all_passed());
462 assert!(r.render_summary().contains("✓"));
463 }
464
465 #[tokio::test]
466 async fn run_self_test_against_unreachable_target_marks_all_failed() {
467 let cfg = SelfTestConfig {
470 target_url: "http://127.0.0.1:1".into(),
471 timeout: Duration::from_millis(200),
472 ..Default::default()
473 };
474 let ops = vec![op(
475 "POST",
476 "/users",
477 Some("{\"name\":\"a\"}"),
478 vec![],
479 vec![],
480 vec![],
481 )];
482 let report = run_self_test(&ops, &cfg).await.expect("client builds");
483 assert_eq!(report.positive_fail, 1);
487 assert!(report.negative_missed.values().sum::<usize>() >= 1);
488 assert!(!report.all_passed());
489 }
490
491 #[tokio::test]
496 async fn no_sample_body_still_produces_request_body_negatives() {
497 let cfg = SelfTestConfig {
498 target_url: "http://127.0.0.1:1".into(),
499 timeout: Duration::from_millis(200),
500 ..Default::default()
501 };
502 let ops = vec![op("POST", "/x", None, vec![], vec![], vec![])];
504 let mut ops_fixed = ops;
506 ops_fixed[0].request_body_content_type = Some("application/json".into());
507 let report = run_self_test(&ops_fixed, &cfg).await.expect("client builds");
508 assert!(
512 report.negative_missed.values().sum::<usize>() >= 2,
513 "expected ≥2 request-body negatives, got {:?}",
514 report.negative_missed
515 );
516 }
517
518 #[tokio::test]
523 async fn path_param_only_endpoint_produces_a_probe() {
524 let cfg = SelfTestConfig {
525 target_url: "http://127.0.0.1:1".into(),
526 timeout: Duration::from_millis(200),
527 ..Default::default()
528 };
529 let ops = vec![op(
530 "GET",
531 "/teams/{team-id}",
532 None,
533 vec![],
534 vec![],
535 vec![("team-id", "1")],
536 )];
537 let report = run_self_test(&ops, &cfg).await.expect("client builds");
538 let total: usize = report.negative_caught.values().sum::<usize>()
539 + report.negative_missed.values().sum::<usize>();
540 assert!(total >= 1, "expected ≥1 path-param probe, got {:?}", report);
541 }
542
543 #[test]
544 fn json_serialises_report() {
545 let r = SelfTestReport {
546 positive_pass: 1,
547 positive_fail: 0,
548 negative_caught: BTreeMap::new(),
549 negative_missed: BTreeMap::new(),
550 operations: vec![OperationResult {
551 method: "GET".into(),
552 path: "/x".into(),
553 positive: Some(CaseOutcome {
554 label: "positive".into(),
555 expected_4xx: false,
556 actual_status: 200,
557 passed: true,
558 }),
559 negatives: Vec::new(),
560 }],
561 };
562 let json = serde_json::to_value(&r).expect("serialises");
563 assert_eq!(json["positive_pass"], serde_json::json!(1));
564 assert_eq!(json["operations"][0]["positive"]["actual_status"], serde_json::json!(200));
565 }
566}