• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

gripmock / grpctestify-rust / 24367988238

13 Apr 2026 09:32PM UTC coverage: 75.093% (-0.4%) from 75.445%
24367988238

Pull #35

github

web-flow
Merge 605a12caf into 4ba0f08f1
Pull Request #35: feat: meta section & refactoring

2518 of 3593 new or added lines in 47 files covered. (70.08%)

155 existing lines in 9 files now uncovered.

16781 of 22347 relevant lines covered (75.09%)

2495.25 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

84.05
/src/parser/validator.rs
1
// GCTF document validator - validates parsed AST
2
// Checks for required sections, conflicts, and data integrity
3

4
use super::ast::*;
5
use anyhow::{Result, bail};
6
use serde::Serialize;
7

8
/// Validation error
9
#[derive(Debug, Clone, Serialize)]
10
pub struct ValidationError {
11
    pub message: String,
12
    pub line: Option<usize>,
13
    pub severity: ErrorSeverity,
14
}
15

16
/// Error severity
17
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
18
#[serde(rename_all = "lowercase")]
19
pub enum ErrorSeverity {
20
    Error,
21
    Warning,
22
    Info,
23
}
24

25
/// Validate a parsed GCTF document (returns all errors/warnings without bailing)
26
pub fn validate_document_diagnostics(document: &GctfDocument) -> Vec<ValidationError> {
10,061✔
27
    let mut errors = Vec::new();
10,061✔
28

29
    // Check for required sections
30
    validate_required_sections(document, &mut errors);
10,061✔
31

32
    // Check for conflicts
33
    validate_conflicts(document, &mut errors);
10,061✔
34

35
    // Validate content
36
    validate_content(document, &mut errors);
10,061✔
37

38
    // Validate structure
39
    validate_structure(document, &mut errors);
10,061✔
40

41
    errors
10,061✔
42
}
10,061✔
43

44
/// Validate a parsed GCTF document (legacy wrapper that bails on error)
45
pub fn validate_document(document: &GctfDocument) -> Result<Vec<ValidationError>> {
10,051✔
46
    let errors = validate_document_diagnostics(document);
10,051✔
47
    let has_errors = errors.iter().any(|e| e.severity == ErrorSeverity::Error);
20,044✔
48

49
    if has_errors {
10,051✔
50
        let error_messages: Vec<String> = errors
10,003✔
51
            .iter()
10,003✔
52
            .filter(|e| e.severity == ErrorSeverity::Error)
20,005✔
53
            .map(|e| format!("Line {}: {}", e.line.unwrap_or(0), e.message))
10,005✔
54
            .collect();
10,003✔
55

56
        bail!("Validation failed:\n{}", error_messages.join("\n"));
10,003✔
57
    }
48✔
58

59
    Ok(errors)
48✔
60
}
10,051✔
61

62
/// Validate required sections
63
fn validate_required_sections(document: &GctfDocument, errors: &mut Vec<ValidationError>) {
10,061✔
64
    // ENDPOINT is required
65
    if document.get_endpoint().is_none() {
10,061✔
66
        errors.push(ValidationError {
1✔
67
            message: "ENDPOINT section is required".to_string(),
1✔
68
            line: None,
1✔
69
            severity: ErrorSeverity::Error,
1✔
70
        });
1✔
71
    }
10,060✔
72

73
    // ADDRESS or environment variable is required
74
    // The address might also be provided via CLI args or Config file, which the validator
75
    // doesn't have access to here. We should probably relax this check or make it a warning.
76
    // Ideally validation happens with full context, but for now let's check env var.
77
    // If neither section nor env var is present, we warn instead of error,
78
    // because it might be supplied at runtime.
79
    let env_addr = std::env::var(crate::config::ENV_GRPCTESTIFY_ADDRESS).ok();
10,061✔
80
    if document.get_address(env_addr.as_deref()).is_none() {
10,061✔
81
        // Downgrade to warning because address can come from CLI/Config
10,041✔
82
        errors.push(ValidationError {
10,041✔
83
            message: format!(
10,041✔
84
                "ADDRESS section missing (ensure {} is set or passed via --address)",
10,041✔
85
                crate::config::ENV_GRPCTESTIFY_ADDRESS
10,041✔
86
            ),
10,041✔
87
            line: None,
10,041✔
88
            severity: ErrorSeverity::Warning,
10,041✔
89
        });
10,041✔
90
    }
10,041✔
91

92
    // At least RESPONSE, ERROR or ASSERTS should be present for verification
93
    let has_response = document.first_section(SectionType::Response).is_some();
10,061✔
94
    let has_error = document.first_section(SectionType::Error).is_some();
10,061✔
95
    let has_asserts = document.first_section(SectionType::Asserts).is_some();
10,061✔
96

97
    if !has_response && !has_error && !has_asserts {
10,061✔
98
        errors.push(ValidationError {
5✔
99
            message: "At least one verification section (RESPONSE, ERROR, or ASSERTS) is required"
5✔
100
                .to_string(),
5✔
101
            line: None,
5✔
102
            severity: ErrorSeverity::Error,
5✔
103
        });
5✔
104
    }
10,056✔
105
}
10,061✔
106

107
/// Validate conflicts
108
fn validate_conflicts(document: &GctfDocument, errors: &mut Vec<ValidationError>) {
10,061✔
109
    // RESPONSE and ERROR cannot both be present
110
    if document.has_response_error_conflict() {
10,061✔
111
        errors.push(ValidationError {
1✔
112
            message: "Cannot have both RESPONSE and ERROR sections".to_string(),
1✔
113
            line: None,
1✔
114
            severity: ErrorSeverity::Error,
1✔
115
        });
1✔
116
    }
10,060✔
117
}
10,061✔
118

119
/// Validate content
120
fn validate_content(document: &GctfDocument, errors: &mut Vec<ValidationError>) {
10,061✔
121
    // Validate endpoint format
122
    if let Some(endpoint) = document.get_endpoint()
10,061✔
123
        && !endpoint.contains('/')
10,060✔
124
    {
125
        errors.push(ValidationError {
10,001✔
126
            message: format!(
10,001✔
127
                "Invalid endpoint format: {}. Expected format: package.Service/Method",
128
                endpoint
129
            ),
130
            line: document
10,001✔
131
                .first_section(SectionType::Endpoint)
10,001✔
132
                .map(|s| s.start_line),
10,001✔
133
            severity: ErrorSeverity::Error,
10,001✔
134
        });
135
    }
60✔
136

137
    // Validate address format
138
    if let Some(address) = document.get_address(None)
10,061✔
139
        && !address.contains(':')
19✔
140
    {
141
        errors.push(ValidationError {
1✔
142
            message: format!(
1✔
143
                "Invalid address format: {}. Expected format: host:port",
144
                address
145
            ),
146
            line: document
1✔
147
                .first_section(SectionType::Address)
1✔
148
                .map(|s| s.start_line),
1✔
149
            severity: ErrorSeverity::Error,
1✔
150
        });
151
    }
10,060✔
152

153
    // Validate JSON sections
154
    for section_type in [
30,183✔
155
        SectionType::Request,
10,061✔
156
        SectionType::Response,
10,061✔
157
        SectionType::Error,
10,061✔
158
    ] {
10,061✔
159
        for section in document.sections_by_type(section_type) {
30,183✔
160
            match &section.content {
20,098✔
161
                SectionContent::Json(json) => {
20,096✔
162
                    // Check if JSON is valid object or array
163
                    // For ERROR, we also allow strings
164
                    let is_valid = if section_type == SectionType::Error {
20,096✔
165
                        json.is_object() || json.is_array() || json.is_string()
4✔
166
                    } else {
167
                        json.is_object() || json.is_array()
20,092✔
168
                    };
169

170
                    if !is_valid {
20,096✔
171
                        errors.push(ValidationError {
×
172
                            message: format!(
×
173
                                "{:?} section must contain valid JSON object or array{}",
174
                                section_type,
175
                                if section_type == SectionType::Error {
×
176
                                    " or string"
×
177
                                } else {
178
                                    ""
×
179
                                }
180
                            ),
181
                            line: Some(section.start_line),
×
182
                            severity: ErrorSeverity::Error,
×
183
                        });
184
                    }
20,096✔
185

186
                    if section_type == SectionType::Error
20,096✔
187
                        && let Some(details) = json.get("details")
4✔
188
                    {
189
                        if !details.is_array() {
2✔
190
                            errors.push(ValidationError {
1✔
191
                                message: "ERROR section field 'details' must be an array"
1✔
192
                                    .to_string(),
1✔
193
                                line: Some(section.start_line),
1✔
194
                                severity: ErrorSeverity::Error,
1✔
195
                            });
1✔
196
                        } else if let Some(detail_items) = details.as_array() {
1✔
197
                            for detail in detail_items {
1✔
198
                                if !detail.is_object() {
1✔
199
                                    errors.push(ValidationError {
1✔
200
                                        message: "ERROR section 'details' items must be objects"
1✔
201
                                            .to_string(),
1✔
202
                                        line: Some(section.start_line),
1✔
203
                                        severity: ErrorSeverity::Error,
1✔
204
                                    });
1✔
205
                                    break;
1✔
206
                                }
×
207

208
                                if let Some(type_value) = detail.get("@type")
×
209
                                    && !type_value.is_string()
×
210
                                {
×
211
                                    errors.push(ValidationError {
×
212
                                        message:
×
213
                                            "ERROR.details item field '@type' must be a string"
×
214
                                                .to_string(),
×
215
                                        line: Some(section.start_line),
×
216
                                        severity: ErrorSeverity::Error,
×
217
                                    });
×
218
                                }
×
219
                            }
220
                        }
×
221
                    }
20,094✔
222
                }
223
                SectionContent::JsonLines(values) => {
1✔
224
                    if section_type != SectionType::Response {
1✔
225
                        errors.push(ValidationError {
×
226
                            message: format!(
×
227
                                "{:?} section does not support newline-delimited JSON messages",
×
228
                                section_type
×
229
                            ),
×
230
                            line: Some(section.start_line),
×
231
                            severity: ErrorSeverity::Error,
×
232
                        });
×
233
                    } else if values.is_empty() {
1✔
234
                        errors.push(ValidationError {
×
235
                            message: "RESPONSE section contains no JSON messages".to_string(),
×
236
                            line: Some(section.start_line),
×
237
                            severity: ErrorSeverity::Error,
×
238
                        });
×
239
                    }
1✔
240
                }
241
                _ => {}
1✔
242
            }
243
        }
244
    }
245

246
    // Validate key-value sections
247
    for section_type in [
40,244✔
248
        SectionType::RequestHeaders,
10,061✔
249
        SectionType::Tls,
10,061✔
250
        SectionType::Proto,
10,061✔
251
        SectionType::Options,
10,061✔
252
    ] {
10,061✔
253
        for section in document.sections_by_type(section_type) {
40,244✔
254
            if let SectionContent::KeyValues(kv) = &section.content {
9✔
255
                // Check for empty keys or values
256
                for key in kv.keys() {
17✔
257
                    if key.is_empty() {
17✔
258
                        errors.push(ValidationError {
×
259
                            message: format!("Empty key in {:?} section", section_type),
×
260
                            line: Some(section.start_line),
×
261
                            severity: ErrorSeverity::Error,
×
262
                        });
×
263
                    }
17✔
264
                }
265

266
                if section_type == SectionType::Options {
9✔
267
                    for (key, value) in kv {
17✔
268
                        match key.as_str() {
17✔
269
                            "timeout" => {
17✔
270
                                if value.trim().parse::<u64>().ok().is_none_or(|v| v == 0) {
7✔
271
                                    errors.push(ValidationError {
1✔
272
                                        message: format!(
1✔
273
                                            "OPTIONS.timeout must be a positive integer, got '{}'",
1✔
274
                                            value
1✔
275
                                        ),
1✔
276
                                        line: Some(section.start_line),
1✔
277
                                        severity: ErrorSeverity::Error,
1✔
278
                                    });
1✔
279
                                }
6✔
280
                            }
281
                            "no-retry" | "no_retry" => {
10✔
282
                                let normalized = value.trim().to_ascii_lowercase();
1✔
283
                                let is_bool = matches!(
1✔
284
                                    normalized.as_str(),
1✔
285
                                    "true" | "1" | "yes" | "on" | "false" | "0" | "no" | "off"
1✔
286
                                );
287
                                if !is_bool {
1✔
288
                                    errors.push(ValidationError {
×
289
                                        message: format!(
×
290
                                            "OPTIONS.{} must be a boolean, got '{}'",
×
291
                                            key, value
×
292
                                        ),
×
293
                                        line: Some(section.start_line),
×
294
                                        severity: ErrorSeverity::Error,
×
295
                                    });
×
296
                                }
1✔
297
                            }
298
                            "retry" => {
9✔
299
                                if value.trim().parse::<u32>().is_err() {
6✔
300
                                    errors.push(ValidationError {
×
301
                                        message: format!(
×
302
                                            "OPTIONS.retry must be a non-negative integer, got '{}'",
×
303
                                            value
×
304
                                        ),
×
305
                                        line: Some(section.start_line),
×
306
                                        severity: ErrorSeverity::Error,
×
307
                                    });
×
308
                                }
6✔
309
                            }
310
                            "retry-delay" | "retry_delay" => {
3✔
311
                                if value.trim().parse::<f64>().ok().is_none_or(|v| v < 0.0) {
1✔
312
                                    errors.push(ValidationError {
×
313
                                        message: format!(
×
314
                                            "OPTIONS.retry-delay must be a non-negative number, got '{}'",
×
315
                                            value
×
316
                                        ),
×
317
                                        line: Some(section.start_line),
×
318
                                        severity: ErrorSeverity::Error,
×
319
                                    });
×
320
                                }
1✔
321
                            }
322
                            _ => {
2✔
323
                                errors.push(ValidationError {
2✔
324
                                    message: format!(
2✔
325
                                        "Unknown OPTIONS key '{}'. Supported keys: timeout, retry, retry-delay, no-retry",
2✔
326
                                        key
2✔
327
                                    ),
2✔
328
                                    line: Some(section.start_line),
2✔
329
                                    severity: ErrorSeverity::Warning,
2✔
330
                                });
2✔
331
                            }
2✔
332
                        }
333
                    }
334
                }
×
335
            }
×
336
        }
337
    }
338

339
    // Validate assertions
340
    for section in document.sections_by_type(SectionType::Asserts) {
10,061✔
341
        if let SectionContent::Assertions(assertions) = &section.content {
5✔
342
            for assertion in assertions {
5✔
343
                if assertion.is_empty() {
5✔
344
                    errors.push(ValidationError {
×
345
                        message: "Empty assertion found".to_string(),
×
346
                        line: Some(section.start_line),
×
347
                        severity: ErrorSeverity::Warning,
×
348
                    });
×
349
                }
5✔
350
            }
351
        }
×
352
    }
353
}
10,061✔
354

355
/// Validate structure
356
fn validate_structure(document: &GctfDocument, errors: &mut Vec<ValidationError>) {
10,061✔
357
    // Check for duplicate non-multiple sections
358
    let mut seen_sections = std::collections::HashSet::new();
10,061✔
359
    let mut meta_count = 0;
10,061✔
360
    let mut meta_first_line = None;
10,061✔
361

362
    for section in &document.sections {
33,591✔
363
        if section.section_type == SectionType::Meta {
33,591✔
NEW
364
            meta_count += 1;
×
NEW
365
            if meta_first_line.is_none() {
×
NEW
366
                meta_first_line = Some(section.start_line);
×
NEW
367
            }
×
368
        }
33,591✔
369

370
        if !section.section_type.is_multiple_allowed() {
33,591✔
371
            if seen_sections.contains(&section.section_type) {
10,092✔
372
                errors.push(ValidationError {
×
373
                    message: format!("Duplicate {:?} section found", section.section_type),
×
374
                    line: Some(section.start_line),
×
375
                    severity: ErrorSeverity::Error,
×
376
                });
×
377
            }
10,092✔
378
            seen_sections.insert(section.section_type);
10,092✔
379
        }
23,499✔
380
    }
381

382
    // Validate META section: only 0 or 1 per file, must be first if present
383
    if meta_count > 1 {
10,061✔
NEW
384
        errors.push(ValidationError {
×
NEW
385
            message: "Only one META section is allowed per file".to_string(),
×
NEW
386
            line: meta_first_line,
×
NEW
387
            severity: ErrorSeverity::Error,
×
NEW
388
        });
×
389
    }
10,061✔
390

391
    // Check META is first section (if present)
392
    if meta_count == 1 {
10,061✔
NEW
393
        if let Some(first_section) = document.sections.first() {
×
NEW
394
            if first_section.section_type != SectionType::Meta {
×
NEW
395
                errors.push(ValidationError {
×
NEW
396
                    message: "META section must be the first section in the file".to_string(),
×
NEW
397
                    line: meta_first_line,
×
NEW
398
                    severity: ErrorSeverity::Error,
×
NEW
399
                });
×
NEW
400
            }
×
NEW
401
        }
×
402
    }
10,061✔
403

404
    // Validate section order (optional, but good for readability)
405
    // Not enforcing strict order, just checking for obvious issues
406
    // TODO: Add optional strict ordering validation
407

408
    // Validate inline options are only on supported sections
409
    for section in &document.sections {
33,591✔
410
        let has_any_inline_options = section.inline_options.with_asserts
33,591✔
411
            || section.inline_options.partial
33,591✔
412
            || section.inline_options.tolerance.is_some()
33,590✔
413
            || !section.inline_options.redact.is_empty()
33,590✔
414
            || section.inline_options.unordered_arrays;
33,590✔
415

416
        if !has_any_inline_options {
33,591✔
417
            continue;
33,590✔
418
        }
1✔
419

420
        match section.section_type {
1✔
421
            SectionType::Response => {
1✔
422
                // All known options are supported for RESPONSE section
1✔
423
            }
1✔
424
            SectionType::Error => {
425
                if section.inline_options.partial
×
426
                    || section.inline_options.tolerance.is_some()
×
427
                    || !section.inline_options.redact.is_empty()
×
428
                    || section.inline_options.unordered_arrays
×
429
                {
×
430
                    errors.push(ValidationError {
×
431
                        message: "ERROR section only supports with_asserts inline option"
×
432
                            .to_string(),
×
433
                        line: Some(section.start_line),
×
434
                        severity: ErrorSeverity::Warning,
×
435
                    });
×
436
                }
×
437
            }
438
            _ => {
×
439
                errors.push(ValidationError {
×
440
                    message: format!(
×
441
                        "Inline options are not supported for {:?} section",
×
442
                        section.section_type
×
443
                    ),
×
444
                    line: Some(section.start_line),
×
445
                    severity: ErrorSeverity::Warning,
×
446
                });
×
447
            }
×
448
        }
449
    }
450
}
10,061✔
451

452
/// Check if validation passed (no errors)
453
pub fn validation_passed(errors: &[ValidationError]) -> bool {
2✔
454
    !errors.iter().any(|e| e.severity == ErrorSeverity::Error)
4✔
455
}
2✔
456

457
#[cfg(test)]
458
mod tests {
459
    use super::*;
460

461
    fn create_test_document() -> GctfDocument {
18✔
462
        let mut doc = GctfDocument::new("test.gctf".to_string());
18✔
463

464
        doc.sections = vec![
18✔
465
            Section {
18✔
466
                section_type: SectionType::Address,
18✔
467
                content: SectionContent::Single("localhost:4770".to_string()),
18✔
468
                inline_options: InlineOptions::default(),
18✔
469
                raw_content: "localhost:4770".to_string(),
18✔
470
                start_line: 1,
18✔
471
                end_line: 1,
18✔
472
            },
18✔
473
            Section {
18✔
474
                section_type: SectionType::Endpoint,
18✔
475
                content: SectionContent::Single("my.Service/Method".to_string()),
18✔
476
                inline_options: InlineOptions::default(),
18✔
477
                raw_content: "my.Service/Method".to_string(),
18✔
478
                start_line: 3,
18✔
479
                end_line: 3,
18✔
480
            },
18✔
481
        ];
482

483
        doc
18✔
484
    }
18✔
485

486
    #[test]
487
    fn test_validate_required_sections_pass() {
1✔
488
        let doc = create_test_document();
1✔
489
        let result = validate_document(&doc);
1✔
490
        // Should fail because no REQUEST or ASSERTS
491
        assert!(result.is_err());
1✔
492
    }
1✔
493

494
    #[test]
495
    fn test_validate_endpoint_format() {
1✔
496
        let mut doc = create_test_document();
1✔
497
        doc.sections[1].content = SectionContent::Single("invalid_endpoint".to_string());
1✔
498

499
        let result = validate_document(&doc);
1✔
500
        assert!(result.is_err());
1✔
501
    }
1✔
502

503
    #[test]
504
    fn test_validate_address_format() {
1✔
505
        let mut doc = create_test_document();
1✔
506
        doc.sections[0].content = SectionContent::Single("invalid_address".to_string());
1✔
507

508
        let result = validate_document(&doc);
1✔
509
        assert!(result.is_err());
1✔
510
    }
1✔
511

512
    #[test]
513
    fn test_validation_passed() {
1✔
514
        let errors = vec![
1✔
515
            ValidationError {
1✔
516
                message: "Warning".to_string(),
1✔
517
                line: Some(1),
1✔
518
                severity: ErrorSeverity::Warning,
1✔
519
            },
1✔
520
            ValidationError {
1✔
521
                message: "Info".to_string(),
1✔
522
                line: Some(2),
1✔
523
                severity: ErrorSeverity::Info,
1✔
524
            },
1✔
525
        ];
526

527
        assert!(validation_passed(&errors));
1✔
528
    }
1✔
529

530
    #[test]
531
    fn test_validation_failed() {
1✔
532
        let errors = vec![
1✔
533
            ValidationError {
1✔
534
                message: "Warning".to_string(),
1✔
535
                line: Some(1),
1✔
536
                severity: ErrorSeverity::Warning,
1✔
537
            },
1✔
538
            ValidationError {
1✔
539
                message: "Error".to_string(),
1✔
540
                line: Some(2),
1✔
541
                severity: ErrorSeverity::Error,
1✔
542
            },
1✔
543
        ];
544

545
        assert!(!validation_passed(&errors));
1✔
546
    }
1✔
547

548
    #[test]
549
    fn test_validate_document_diagnostics() {
1✔
550
        let doc = create_test_document();
1✔
551
        let errors = validate_document_diagnostics(&doc);
1✔
552
        // Should have some errors or warnings
553
        assert!(!errors.is_empty());
1✔
554
    }
1✔
555

556
    #[test]
557
    fn test_validate_document_with_response() {
1✔
558
        let mut doc = create_test_document();
1✔
559
        doc.sections.push(Section {
1✔
560
            section_type: SectionType::Response,
1✔
561
            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1✔
562
            inline_options: InlineOptions::default(),
1✔
563
            raw_content: "{\"result\": \"ok\"}".to_string(),
1✔
564
            start_line: 5,
1✔
565
            end_line: 6,
1✔
566
        });
1✔
567

568
        let result = validate_document(&doc);
1✔
569
        // Should pass with ADDRESS, ENDPOINT, and RESPONSE
570
        assert!(result.is_ok());
1✔
571
    }
1✔
572

573
    #[test]
574
    fn test_validate_document_with_error_section() {
1✔
575
        let mut doc = create_test_document();
1✔
576
        doc.sections.push(Section {
1✔
577
            section_type: SectionType::Error,
1✔
578
            content: SectionContent::Json(serde_json::json!({"code": 5})),
1✔
579
            inline_options: InlineOptions::default(),
1✔
580
            raw_content: "{\"code\": 5}".to_string(),
1✔
581
            start_line: 5,
1✔
582
            end_line: 6,
1✔
583
        });
1✔
584

585
        let result = validate_document(&doc);
1✔
586
        // Should pass with ADDRESS, ENDPOINT, and ERROR
587
        assert!(result.is_ok());
1✔
588
    }
1✔
589

590
    #[test]
591
    fn test_validate_document_with_asserts() {
1✔
592
        let mut doc = create_test_document();
1✔
593
        doc.sections.push(Section {
1✔
594
            section_type: SectionType::Asserts,
1✔
595
            content: SectionContent::Assertions(vec![".id == 1".to_string()]),
1✔
596
            inline_options: InlineOptions::default(),
1✔
597
            raw_content: ".id == 1".to_string(),
1✔
598
            start_line: 5,
1✔
599
            end_line: 5,
1✔
600
        });
1✔
601

602
        let result = validate_document(&doc);
1✔
603
        // Should pass with ADDRESS, ENDPOINT, and ASSERTS
604
        assert!(result.is_ok());
1✔
605
    }
1✔
606

607
    #[test]
608
    fn test_validate_document_missing_endpoint() {
1✔
609
        let mut doc = create_test_document();
1✔
610
        doc.sections.remove(1); // Remove ENDPOINT
1✔
611

612
        let errors = validate_document_diagnostics(&doc);
1✔
613
        let has_endpoint_error = errors.iter().any(|e| e.message.contains("ENDPOINT"));
1✔
614
        assert!(has_endpoint_error);
1✔
615
    }
1✔
616

617
    #[test]
618
    fn test_validate_document_response_error_conflict() {
1✔
619
        let mut doc = create_test_document();
1✔
620
        doc.sections.push(Section {
1✔
621
            section_type: SectionType::Response,
1✔
622
            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1✔
623
            inline_options: InlineOptions::default(),
1✔
624
            raw_content: "{\"result\": \"ok\"}".to_string(),
1✔
625
            start_line: 5,
1✔
626
            end_line: 6,
1✔
627
        });
1✔
628
        doc.sections.push(Section {
1✔
629
            section_type: SectionType::Error,
1✔
630
            content: SectionContent::Json(serde_json::json!({"code": 5})),
1✔
631
            inline_options: InlineOptions::default(),
1✔
632
            raw_content: "{\"code\": 5}".to_string(),
1✔
633
            start_line: 7,
1✔
634
            end_line: 8,
1✔
635
        });
1✔
636

637
        let errors = validate_document_diagnostics(&doc);
1✔
638
        let has_conflict_error = errors
1✔
639
            .iter()
1✔
640
            .any(|e| e.message.contains("RESPONSE") && e.message.contains("ERROR"));
1✔
641
        assert!(has_conflict_error);
1✔
642
    }
1✔
643

644
    #[test]
645
    fn test_validate_document_empty_requests() {
1✔
646
        let mut doc = create_test_document();
1✔
647
        doc.sections.push(Section {
1✔
648
            section_type: SectionType::Request,
1✔
649
            content: SectionContent::Empty,
1✔
650
            inline_options: InlineOptions::default(),
1✔
651
            raw_content: "".to_string(),
1✔
652
            start_line: 5,
1✔
653
            end_line: 5,
1✔
654
        });
1✔
655
        doc.sections.push(Section {
1✔
656
            section_type: SectionType::Response,
1✔
657
            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1✔
658
            inline_options: InlineOptions::default(),
1✔
659
            raw_content: "{\"result\": \"ok\"}".to_string(),
1✔
660
            start_line: 6,
1✔
661
            end_line: 7,
1✔
662
        });
1✔
663

664
        let result = validate_document(&doc);
1✔
665
        // Empty REQUEST is allowed
666
        assert!(result.is_ok());
1✔
667
    }
1✔
668

669
    #[test]
670
    fn test_validate_document_invalid_request_json() {
1✔
671
        let mut doc = create_test_document();
1✔
672
        doc.sections.push(Section {
1✔
673
            section_type: SectionType::Request,
1✔
674
            content: SectionContent::Json(serde_json::json!({"key": "value"})),
1✔
675
            inline_options: InlineOptions::default(),
1✔
676
            raw_content: "{\"key\": \"value\"}".to_string(),
1✔
677
            start_line: 5,
1✔
678
            end_line: 6,
1✔
679
        });
1✔
680
        doc.sections.push(Section {
1✔
681
            section_type: SectionType::Response,
1✔
682
            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1✔
683
            inline_options: InlineOptions::default(),
1✔
684
            raw_content: "{\"result\": \"ok\"}".to_string(),
1✔
685
            start_line: 7,
1✔
686
            end_line: 8,
1✔
687
        });
1✔
688

689
        let result = validate_document(&doc);
1✔
690
        // Valid JSON should pass
691
        assert!(result.is_ok());
1✔
692
    }
1✔
693

694
    #[test]
695
    fn test_validate_document_invalid_response_json() {
1✔
696
        let mut doc = create_test_document();
1✔
697
        doc.sections.push(Section {
1✔
698
            section_type: SectionType::Request,
1✔
699
            content: SectionContent::Json(serde_json::json!({"key": "value"})),
1✔
700
            inline_options: InlineOptions::default(),
1✔
701
            raw_content: "{\"key\": \"value\"}".to_string(),
1✔
702
            start_line: 5,
1✔
703
            end_line: 6,
1✔
704
        });
1✔
705
        doc.sections.push(Section {
1✔
706
            section_type: SectionType::Response,
1✔
707
            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1✔
708
            inline_options: InlineOptions::default(),
1✔
709
            raw_content: "{\"result\": \"ok\"}".to_string(),
1✔
710
            start_line: 7,
1✔
711
            end_line: 8,
1✔
712
        });
1✔
713

714
        let errors = validate_document_diagnostics(&doc);
1✔
715
        // Valid JSON should have no errors
716
        let has_json_errors = errors.iter().any(|e| e.message.contains("JSON"));
1✔
717
        assert!(!has_json_errors);
1✔
718
    }
1✔
719

720
    #[test]
721
    fn test_validate_error_details_must_be_array() {
1✔
722
        let mut doc = create_test_document();
1✔
723
        doc.sections.push(Section {
1✔
724
            section_type: SectionType::Error,
1✔
725
            content: SectionContent::Json(serde_json::json!({
1✔
726
                "code": 3,
1✔
727
                "details": {"@type": "type.googleapis.com/google.rpc.ErrorInfo"}
1✔
728
            })),
1✔
729
            inline_options: InlineOptions::default(),
1✔
730
            raw_content: "".to_string(),
1✔
731
            start_line: 5,
1✔
732
            end_line: 8,
1✔
733
        });
1✔
734

735
        let errors = validate_document_diagnostics(&doc);
1✔
736
        assert!(
1✔
737
            errors
1✔
738
                .iter()
1✔
739
                .any(|e| e.message.contains("field 'details' must be an array"))
1✔
740
        );
741
    }
1✔
742

743
    #[test]
744
    fn test_validate_error_details_items_must_be_objects() {
1✔
745
        let mut doc = create_test_document();
1✔
746
        doc.sections.push(Section {
1✔
747
            section_type: SectionType::Error,
1✔
748
            content: SectionContent::Json(serde_json::json!({
1✔
749
                "code": 3,
1✔
750
                "details": ["not-an-object"]
1✔
751
            })),
1✔
752
            inline_options: InlineOptions::default(),
1✔
753
            raw_content: "".to_string(),
1✔
754
            start_line: 5,
1✔
755
            end_line: 8,
1✔
756
        });
1✔
757

758
        let errors = validate_document_diagnostics(&doc);
1✔
759
        assert!(
1✔
760
            errors
1✔
761
                .iter()
1✔
762
                .any(|e| e.message.contains("'details' items must be objects"))
1✔
763
        );
764
    }
1✔
765

766
    #[test]
767
    fn test_validate_document_address_from_env() {
1✔
768
        // Set env var
769
        unsafe {
1✔
770
            std::env::set_var(crate::config::ENV_GRPCTESTIFY_ADDRESS, "env:5000");
1✔
771
        }
1✔
772

773
        let mut doc = GctfDocument::new("test.gctf".to_string());
1✔
774
        doc.sections.push(Section {
1✔
775
            section_type: SectionType::Endpoint,
1✔
776
            content: SectionContent::Single("Service/Method".to_string()),
1✔
777
            inline_options: InlineOptions::default(),
1✔
778
            raw_content: "Service/Method".to_string(),
1✔
779
            start_line: 1,
1✔
780
            end_line: 1,
1✔
781
        });
1✔
782
        doc.sections.push(Section {
1✔
783
            section_type: SectionType::Response,
1✔
784
            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1✔
785
            inline_options: InlineOptions::default(),
1✔
786
            raw_content: "{\"result\": \"ok\"}".to_string(),
1✔
787
            start_line: 2,
1✔
788
            end_line: 3,
1✔
789
        });
1✔
790

791
        let result = validate_document(&doc);
1✔
792
        // Should pass because address comes from env
793
        assert!(result.is_ok());
1✔
794

795
        // Clean up
796
        unsafe {
1✔
797
            std::env::remove_var(crate::config::ENV_GRPCTESTIFY_ADDRESS);
1✔
798
        }
1✔
799
    }
1✔
800

801
    #[test]
802
    fn test_validate_options_unknown_key_warning() {
1✔
803
        let mut doc = create_test_document();
1✔
804
        let mut options = std::collections::HashMap::new();
1✔
805
        options.insert("unknown".to_string(), "value".to_string());
1✔
806
        doc.sections.push(Section {
1✔
807
            section_type: SectionType::Options,
1✔
808
            content: SectionContent::KeyValues(options),
1✔
809
            inline_options: InlineOptions::default(),
1✔
810
            raw_content: "unknown: value".to_string(),
1✔
811
            start_line: 5,
1✔
812
            end_line: 6,
1✔
813
        });
1✔
814
        doc.sections.push(Section {
1✔
815
            section_type: SectionType::Response,
1✔
816
            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1✔
817
            inline_options: InlineOptions::default(),
1✔
818
            raw_content: "{\"result\": \"ok\"}".to_string(),
1✔
819
            start_line: 7,
1✔
820
            end_line: 8,
1✔
821
        });
1✔
822

823
        let diagnostics = validate_document_diagnostics(&doc);
1✔
824
        assert!(diagnostics.iter().any(|d| {
1✔
825
            d.severity == ErrorSeverity::Warning && d.message.contains("Unknown OPTIONS key")
1✔
826
        }));
1✔
827
    }
1✔
828

829
    #[test]
830
    fn test_validate_options_dry_run_is_unknown_key_warning() {
1✔
831
        let mut doc = create_test_document();
1✔
832
        let mut options = std::collections::HashMap::new();
1✔
833
        options.insert("dry_run".to_string(), "true".to_string());
1✔
834
        doc.sections.push(Section {
1✔
835
            section_type: SectionType::Options,
1✔
836
            content: SectionContent::KeyValues(options),
1✔
837
            inline_options: InlineOptions::default(),
1✔
838
            raw_content: "dry_run: true".to_string(),
1✔
839
            start_line: 5,
1✔
840
            end_line: 6,
1✔
841
        });
1✔
842
        doc.sections.push(Section {
1✔
843
            section_type: SectionType::Response,
1✔
844
            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1✔
845
            inline_options: InlineOptions::default(),
1✔
846
            raw_content: "{\"result\": \"ok\"}".to_string(),
1✔
847
            start_line: 7,
1✔
848
            end_line: 8,
1✔
849
        });
1✔
850

851
        let diagnostics = validate_document_diagnostics(&doc);
1✔
852
        assert!(diagnostics.iter().any(|d| {
1✔
853
            d.severity == ErrorSeverity::Warning
1✔
854
                && d.message
1✔
855
                    .contains("Unknown OPTIONS key 'dry_run'. Supported keys: timeout, retry, retry-delay, no-retry")
1✔
856
        }));
1✔
857
    }
1✔
858

859
    #[test]
860
    fn test_validate_options_timeout_invalid_error() {
1✔
861
        let mut doc = create_test_document();
1✔
862
        let mut options = std::collections::HashMap::new();
1✔
863
        options.insert("timeout".to_string(), "0".to_string());
1✔
864
        doc.sections.push(Section {
1✔
865
            section_type: SectionType::Options,
1✔
866
            content: SectionContent::KeyValues(options),
1✔
867
            inline_options: InlineOptions::default(),
1✔
868
            raw_content: "timeout: 0".to_string(),
1✔
869
            start_line: 5,
1✔
870
            end_line: 6,
1✔
871
        });
1✔
872
        doc.sections.push(Section {
1✔
873
            section_type: SectionType::Response,
1✔
874
            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1✔
875
            inline_options: InlineOptions::default(),
1✔
876
            raw_content: "{\"result\": \"ok\"}".to_string(),
1✔
877
            start_line: 7,
1✔
878
            end_line: 8,
1✔
879
        });
1✔
880

881
        let diagnostics = validate_document_diagnostics(&doc);
1✔
882
        assert!(diagnostics.iter().any(|d| {
1✔
883
            d.severity == ErrorSeverity::Error
1✔
884
                && d.message
1✔
885
                    .contains("OPTIONS.timeout must be a positive integer")
1✔
886
        }));
1✔
887
    }
1✔
888

889
    #[test]
890
    fn test_validate_options_kebab_case_keys_are_supported() {
1✔
891
        let mut doc = create_test_document();
1✔
892
        let mut options = std::collections::HashMap::new();
1✔
893
        options.insert("timeout".to_string(), "5".to_string());
1✔
894
        options.insert("retry".to_string(), "2".to_string());
1✔
895
        options.insert("retry-delay".to_string(), "0.5".to_string());
1✔
896
        options.insert("no-retry".to_string(), "false".to_string());
1✔
897
        doc.sections.push(Section {
1✔
898
            section_type: SectionType::Options,
1✔
899
            content: SectionContent::KeyValues(options),
1✔
900
            inline_options: InlineOptions::default(),
1✔
901
            raw_content: "".to_string(),
1✔
902
            start_line: 5,
1✔
903
            end_line: 8,
1✔
904
        });
1✔
905
        doc.sections.push(Section {
1✔
906
            section_type: SectionType::Response,
1✔
907
            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1✔
908
            inline_options: InlineOptions::default(),
1✔
909
            raw_content: "{\"result\": \"ok\"}".to_string(),
1✔
910
            start_line: 9,
1✔
911
            end_line: 10,
1✔
912
        });
1✔
913

914
        let diagnostics = validate_document_diagnostics(&doc);
1✔
915
        assert!(
1✔
916
            !diagnostics
1✔
917
                .iter()
1✔
918
                .any(|d| d.message.contains("Unknown OPTIONS key"))
1✔
919
        );
920
        assert!(
1✔
921
            !diagnostics
1✔
922
                .iter()
1✔
923
                .any(|d| d.severity == ErrorSeverity::Error)
1✔
924
        );
925
    }
1✔
926

927
    #[test]
928
    fn test_validation_error_debug() {
1✔
929
        let error = ValidationError {
1✔
930
            message: "test error".to_string(),
1✔
931
            line: Some(10),
1✔
932
            severity: ErrorSeverity::Error,
1✔
933
        };
1✔
934
        let debug_str = format!("{:?}", error);
1✔
935
        assert!(debug_str.contains("ValidationError"));
1✔
936
        assert!(debug_str.contains("test error"));
1✔
937
    }
1✔
938

939
    #[test]
940
    fn test_error_severity_serialize() {
1✔
941
        let error = ErrorSeverity::Error;
1✔
942
        let json = serde_json::to_string(&error).unwrap();
1✔
943
        assert_eq!(json, "\"error\"");
1✔
944

945
        let warning = ErrorSeverity::Warning;
1✔
946
        let json = serde_json::to_string(&warning).unwrap();
1✔
947
        assert_eq!(json, "\"warning\"");
1✔
948
    }
1✔
949
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc