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

gripmock / grpctestify-rust / 24369349915

13 Apr 2026 10:06PM UTC coverage: 76.457% (+1.0%) from 75.445%
24369349915

Pull #35

github

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

2929 of 3902 new or added lines in 48 files covered. (75.06%)

155 existing lines in 9 files now uncovered.

17309 of 22639 relevant lines covered (76.46%)

2463.13 hits per line

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

85.0
/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,062✔
27
    let mut errors = Vec::new();
10,062✔
28

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

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

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

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

41
    errors
10,062✔
42
}
10,062✔
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,062✔
64
    // ENDPOINT is required
65
    if document.get_endpoint().is_none() {
10,062✔
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,061✔
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,062✔
80
    if document.get_address(env_addr.as_deref()).is_none() {
10,062✔
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,062✔
94
    let has_error = document.first_section(SectionType::Error).is_some();
10,062✔
95
    let has_asserts = document.first_section(SectionType::Asserts).is_some();
10,062✔
96

97
    if !has_response && !has_error && !has_asserts {
10,062✔
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,057✔
105
}
10,062✔
106

107
/// Validate conflicts
108
fn validate_conflicts(document: &GctfDocument, errors: &mut Vec<ValidationError>) {
10,062✔
109
    // RESPONSE and ERROR cannot both be present
110
    if document.has_response_error_conflict() {
10,062✔
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,061✔
117
}
10,062✔
118

119
/// Validate content
120
fn validate_content(document: &GctfDocument, errors: &mut Vec<ValidationError>) {
10,062✔
121
    // Validate endpoint format
122
    if let Some(endpoint) = document.get_endpoint()
10,062✔
123
        && !endpoint.contains('/')
10,061✔
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
    }
61✔
136

137
    // Validate address format
138
    if let Some(address) = document.get_address(None)
10,062✔
139
        && !address.contains(':')
20✔
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,061✔
152

153
    // Validate JSON sections
154
    for section_type in [
30,186✔
155
        SectionType::Request,
10,062✔
156
        SectionType::Response,
10,062✔
157
        SectionType::Error,
10,062✔
158
    ] {
10,062✔
159
        for section in document.sections_by_type(section_type) {
30,186✔
160
            match &section.content {
20,099✔
161
                SectionContent::Json(json) => {
20,097✔
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,097✔
165
                        json.is_object() || json.is_array() || json.is_string()
4✔
166
                    } else {
167
                        json.is_object() || json.is_array()
20,093✔
168
                    };
169

170
                    if !is_valid {
20,097✔
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,097✔
185

186
                    if section_type == SectionType::Error
20,097✔
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,095✔
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,248✔
248
        SectionType::RequestHeaders,
10,062✔
249
        SectionType::Tls,
10,062✔
250
        SectionType::Proto,
10,062✔
251
        SectionType::Options,
10,062✔
252
    ] {
10,062✔
253
        for section in document.sections_by_type(section_type) {
40,248✔
254
            if let SectionContent::KeyValues(kv) = &section.content {
10✔
255
                // Check for empty keys or values
256
                for key in kv.keys() {
19✔
257
                    if key.is_empty() {
19✔
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
                    }
19✔
264
                }
265

266
                if section_type == SectionType::Options {
10✔
267
                    for (key, value) in kv {
19✔
268
                        match key.as_str() {
19✔
269
                            "timeout" => {
19✔
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" => {
12✔
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" => {
11✔
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" => {
5✔
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
                            "compression" => {
4✔
323
                                let normalized = value.trim().to_ascii_lowercase();
2✔
324
                                if !matches!(normalized.as_str(), "none" | "gzip") {
2✔
325
                                    errors.push(ValidationError {
1✔
326
                                        message: format!(
1✔
327
                                            "OPTIONS.compression must be one of: none, gzip (got '{}')",
1✔
328
                                            value
1✔
329
                                        ),
1✔
330
                                        line: Some(section.start_line),
1✔
331
                                        severity: ErrorSeverity::Error,
1✔
332
                                    });
1✔
333
                                }
1✔
334
                            }
335
                            _ => {
2✔
336
                                errors.push(ValidationError {
2✔
337
                                    message: format!(
2✔
338
                                        "Unknown OPTIONS key '{}'. Supported keys: timeout, retry, retry-delay, no-retry, compression",
2✔
339
                                        key
2✔
340
                                    ),
2✔
341
                                    line: Some(section.start_line),
2✔
342
                                    severity: ErrorSeverity::Warning,
2✔
343
                                });
2✔
344
                            }
2✔
345
                        }
346
                    }
347
                }
×
348
            }
×
349
        }
350
    }
351

352
    // Validate assertions
353
    for section in document.sections_by_type(SectionType::Asserts) {
10,062✔
354
        if let SectionContent::Assertions(assertions) = &section.content {
5✔
355
            for assertion in assertions {
5✔
356
                if assertion.is_empty() {
5✔
357
                    errors.push(ValidationError {
×
358
                        message: "Empty assertion found".to_string(),
×
359
                        line: Some(section.start_line),
×
360
                        severity: ErrorSeverity::Warning,
×
361
                    });
×
362
                }
5✔
363
            }
364
        }
×
365
    }
366
}
10,062✔
367

368
/// Validate structure
369
fn validate_structure(document: &GctfDocument, errors: &mut Vec<ValidationError>) {
10,062✔
370
    // Check for duplicate non-multiple sections
371
    let mut seen_sections = std::collections::HashSet::new();
10,062✔
372
    let mut meta_count = 0;
10,062✔
373
    let mut meta_first_line = None;
10,062✔
374

375
    for section in &document.sections {
33,595✔
376
        if section.section_type == SectionType::Meta {
33,595✔
NEW
377
            meta_count += 1;
×
NEW
378
            if meta_first_line.is_none() {
×
NEW
379
                meta_first_line = Some(section.start_line);
×
NEW
380
            }
×
381
        }
33,595✔
382

383
        if !section.section_type.is_multiple_allowed() {
33,595✔
384
            if seen_sections.contains(&section.section_type) {
10,095✔
385
                errors.push(ValidationError {
×
386
                    message: format!("Duplicate {:?} section found", section.section_type),
×
387
                    line: Some(section.start_line),
×
388
                    severity: ErrorSeverity::Error,
×
389
                });
×
390
            }
10,095✔
391
            seen_sections.insert(section.section_type);
10,095✔
392
        }
23,500✔
393
    }
394

395
    // Validate META section: only 0 or 1 per file, must be first if present
396
    if meta_count > 1 {
10,062✔
NEW
397
        errors.push(ValidationError {
×
NEW
398
            message: "Only one META section is allowed per file".to_string(),
×
NEW
399
            line: meta_first_line,
×
NEW
400
            severity: ErrorSeverity::Error,
×
NEW
401
        });
×
402
    }
10,062✔
403

404
    // Check META is first section (if present)
405
    if meta_count == 1
10,062✔
NEW
406
        && let Some(first_section) = document.sections.first()
×
NEW
407
        && first_section.section_type != SectionType::Meta
×
NEW
408
    {
×
NEW
409
        errors.push(ValidationError {
×
NEW
410
            message: "META section must be the first section in the file".to_string(),
×
NEW
411
            line: meta_first_line,
×
NEW
412
            severity: ErrorSeverity::Error,
×
NEW
413
        });
×
414
    }
10,062✔
415

416
    // Validate section order (optional, but good for readability)
417
    // Not enforcing strict order, just checking for obvious issues
418
    // TODO: Add optional strict ordering validation
419

420
    // Validate inline options are only on supported sections
421
    for section in &document.sections {
33,595✔
422
        let has_any_inline_options = section.inline_options.with_asserts
33,595✔
423
            || section.inline_options.partial
33,595✔
424
            || section.inline_options.tolerance.is_some()
33,594✔
425
            || !section.inline_options.redact.is_empty()
33,594✔
426
            || section.inline_options.unordered_arrays;
33,594✔
427

428
        if !has_any_inline_options {
33,595✔
429
            continue;
33,594✔
430
        }
1✔
431

432
        match section.section_type {
1✔
433
            SectionType::Response => {
1✔
434
                // All known options are supported for RESPONSE section
1✔
435
            }
1✔
436
            SectionType::Error => {
437
                if section.inline_options.partial
×
438
                    || section.inline_options.tolerance.is_some()
×
439
                    || !section.inline_options.redact.is_empty()
×
440
                    || section.inline_options.unordered_arrays
×
441
                {
×
442
                    errors.push(ValidationError {
×
443
                        message: "ERROR section only supports with_asserts inline option"
×
444
                            .to_string(),
×
445
                        line: Some(section.start_line),
×
446
                        severity: ErrorSeverity::Warning,
×
447
                    });
×
448
                }
×
449
            }
450
            _ => {
×
451
                errors.push(ValidationError {
×
452
                    message: format!(
×
453
                        "Inline options are not supported for {:?} section",
×
454
                        section.section_type
×
455
                    ),
×
456
                    line: Some(section.start_line),
×
457
                    severity: ErrorSeverity::Warning,
×
458
                });
×
459
            }
×
460
        }
461
    }
462
}
10,062✔
463

464
/// Check if validation passed (no errors)
465
pub fn validation_passed(errors: &[ValidationError]) -> bool {
2✔
466
    !errors.iter().any(|e| e.severity == ErrorSeverity::Error)
4✔
467
}
2✔
468

469
#[cfg(test)]
470
mod tests {
471
    use super::*;
472

473
    fn create_test_document() -> GctfDocument {
19✔
474
        let mut doc = GctfDocument::new("test.gctf".to_string());
19✔
475

476
        doc.sections = vec![
19✔
477
            Section {
19✔
478
                section_type: SectionType::Address,
19✔
479
                content: SectionContent::Single("localhost:4770".to_string()),
19✔
480
                inline_options: InlineOptions::default(),
19✔
481
                raw_content: "localhost:4770".to_string(),
19✔
482
                start_line: 1,
19✔
483
                end_line: 1,
19✔
484
            },
19✔
485
            Section {
19✔
486
                section_type: SectionType::Endpoint,
19✔
487
                content: SectionContent::Single("my.Service/Method".to_string()),
19✔
488
                inline_options: InlineOptions::default(),
19✔
489
                raw_content: "my.Service/Method".to_string(),
19✔
490
                start_line: 3,
19✔
491
                end_line: 3,
19✔
492
            },
19✔
493
        ];
494

495
        doc
19✔
496
    }
19✔
497

498
    #[test]
499
    fn test_validate_required_sections_pass() {
1✔
500
        let doc = create_test_document();
1✔
501
        let result = validate_document(&doc);
1✔
502
        // Should fail because no REQUEST or ASSERTS
503
        assert!(result.is_err());
1✔
504
    }
1✔
505

506
    #[test]
507
    fn test_validate_endpoint_format() {
1✔
508
        let mut doc = create_test_document();
1✔
509
        doc.sections[1].content = SectionContent::Single("invalid_endpoint".to_string());
1✔
510

511
        let result = validate_document(&doc);
1✔
512
        assert!(result.is_err());
1✔
513
    }
1✔
514

515
    #[test]
516
    fn test_validate_address_format() {
1✔
517
        let mut doc = create_test_document();
1✔
518
        doc.sections[0].content = SectionContent::Single("invalid_address".to_string());
1✔
519

520
        let result = validate_document(&doc);
1✔
521
        assert!(result.is_err());
1✔
522
    }
1✔
523

524
    #[test]
525
    fn test_validation_passed() {
1✔
526
        let errors = vec![
1✔
527
            ValidationError {
1✔
528
                message: "Warning".to_string(),
1✔
529
                line: Some(1),
1✔
530
                severity: ErrorSeverity::Warning,
1✔
531
            },
1✔
532
            ValidationError {
1✔
533
                message: "Info".to_string(),
1✔
534
                line: Some(2),
1✔
535
                severity: ErrorSeverity::Info,
1✔
536
            },
1✔
537
        ];
538

539
        assert!(validation_passed(&errors));
1✔
540
    }
1✔
541

542
    #[test]
543
    fn test_validation_failed() {
1✔
544
        let errors = vec![
1✔
545
            ValidationError {
1✔
546
                message: "Warning".to_string(),
1✔
547
                line: Some(1),
1✔
548
                severity: ErrorSeverity::Warning,
1✔
549
            },
1✔
550
            ValidationError {
1✔
551
                message: "Error".to_string(),
1✔
552
                line: Some(2),
1✔
553
                severity: ErrorSeverity::Error,
1✔
554
            },
1✔
555
        ];
556

557
        assert!(!validation_passed(&errors));
1✔
558
    }
1✔
559

560
    #[test]
561
    fn test_validate_document_diagnostics() {
1✔
562
        let doc = create_test_document();
1✔
563
        let errors = validate_document_diagnostics(&doc);
1✔
564
        // Should have some errors or warnings
565
        assert!(!errors.is_empty());
1✔
566
    }
1✔
567

568
    #[test]
569
    fn test_validate_document_with_response() {
1✔
570
        let mut doc = create_test_document();
1✔
571
        doc.sections.push(Section {
1✔
572
            section_type: SectionType::Response,
1✔
573
            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1✔
574
            inline_options: InlineOptions::default(),
1✔
575
            raw_content: "{\"result\": \"ok\"}".to_string(),
1✔
576
            start_line: 5,
1✔
577
            end_line: 6,
1✔
578
        });
1✔
579

580
        let result = validate_document(&doc);
1✔
581
        // Should pass with ADDRESS, ENDPOINT, and RESPONSE
582
        assert!(result.is_ok());
1✔
583
    }
1✔
584

585
    #[test]
586
    fn test_validate_document_with_error_section() {
1✔
587
        let mut doc = create_test_document();
1✔
588
        doc.sections.push(Section {
1✔
589
            section_type: SectionType::Error,
1✔
590
            content: SectionContent::Json(serde_json::json!({"code": 5})),
1✔
591
            inline_options: InlineOptions::default(),
1✔
592
            raw_content: "{\"code\": 5}".to_string(),
1✔
593
            start_line: 5,
1✔
594
            end_line: 6,
1✔
595
        });
1✔
596

597
        let result = validate_document(&doc);
1✔
598
        // Should pass with ADDRESS, ENDPOINT, and ERROR
599
        assert!(result.is_ok());
1✔
600
    }
1✔
601

602
    #[test]
603
    fn test_validate_document_with_asserts() {
1✔
604
        let mut doc = create_test_document();
1✔
605
        doc.sections.push(Section {
1✔
606
            section_type: SectionType::Asserts,
1✔
607
            content: SectionContent::Assertions(vec![".id == 1".to_string()]),
1✔
608
            inline_options: InlineOptions::default(),
1✔
609
            raw_content: ".id == 1".to_string(),
1✔
610
            start_line: 5,
1✔
611
            end_line: 5,
1✔
612
        });
1✔
613

614
        let result = validate_document(&doc);
1✔
615
        // Should pass with ADDRESS, ENDPOINT, and ASSERTS
616
        assert!(result.is_ok());
1✔
617
    }
1✔
618

619
    #[test]
620
    fn test_validate_document_missing_endpoint() {
1✔
621
        let mut doc = create_test_document();
1✔
622
        doc.sections.remove(1); // Remove ENDPOINT
1✔
623

624
        let errors = validate_document_diagnostics(&doc);
1✔
625
        let has_endpoint_error = errors.iter().any(|e| e.message.contains("ENDPOINT"));
1✔
626
        assert!(has_endpoint_error);
1✔
627
    }
1✔
628

629
    #[test]
630
    fn test_validate_document_response_error_conflict() {
1✔
631
        let mut doc = create_test_document();
1✔
632
        doc.sections.push(Section {
1✔
633
            section_type: SectionType::Response,
1✔
634
            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1✔
635
            inline_options: InlineOptions::default(),
1✔
636
            raw_content: "{\"result\": \"ok\"}".to_string(),
1✔
637
            start_line: 5,
1✔
638
            end_line: 6,
1✔
639
        });
1✔
640
        doc.sections.push(Section {
1✔
641
            section_type: SectionType::Error,
1✔
642
            content: SectionContent::Json(serde_json::json!({"code": 5})),
1✔
643
            inline_options: InlineOptions::default(),
1✔
644
            raw_content: "{\"code\": 5}".to_string(),
1✔
645
            start_line: 7,
1✔
646
            end_line: 8,
1✔
647
        });
1✔
648

649
        let errors = validate_document_diagnostics(&doc);
1✔
650
        let has_conflict_error = errors
1✔
651
            .iter()
1✔
652
            .any(|e| e.message.contains("RESPONSE") && e.message.contains("ERROR"));
1✔
653
        assert!(has_conflict_error);
1✔
654
    }
1✔
655

656
    #[test]
657
    fn test_validate_document_empty_requests() {
1✔
658
        let mut doc = create_test_document();
1✔
659
        doc.sections.push(Section {
1✔
660
            section_type: SectionType::Request,
1✔
661
            content: SectionContent::Empty,
1✔
662
            inline_options: InlineOptions::default(),
1✔
663
            raw_content: "".to_string(),
1✔
664
            start_line: 5,
1✔
665
            end_line: 5,
1✔
666
        });
1✔
667
        doc.sections.push(Section {
1✔
668
            section_type: SectionType::Response,
1✔
669
            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1✔
670
            inline_options: InlineOptions::default(),
1✔
671
            raw_content: "{\"result\": \"ok\"}".to_string(),
1✔
672
            start_line: 6,
1✔
673
            end_line: 7,
1✔
674
        });
1✔
675

676
        let result = validate_document(&doc);
1✔
677
        // Empty REQUEST is allowed
678
        assert!(result.is_ok());
1✔
679
    }
1✔
680

681
    #[test]
682
    fn test_validate_document_invalid_request_json() {
1✔
683
        let mut doc = create_test_document();
1✔
684
        doc.sections.push(Section {
1✔
685
            section_type: SectionType::Request,
1✔
686
            content: SectionContent::Json(serde_json::json!({"key": "value"})),
1✔
687
            inline_options: InlineOptions::default(),
1✔
688
            raw_content: "{\"key\": \"value\"}".to_string(),
1✔
689
            start_line: 5,
1✔
690
            end_line: 6,
1✔
691
        });
1✔
692
        doc.sections.push(Section {
1✔
693
            section_type: SectionType::Response,
1✔
694
            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1✔
695
            inline_options: InlineOptions::default(),
1✔
696
            raw_content: "{\"result\": \"ok\"}".to_string(),
1✔
697
            start_line: 7,
1✔
698
            end_line: 8,
1✔
699
        });
1✔
700

701
        let result = validate_document(&doc);
1✔
702
        // Valid JSON should pass
703
        assert!(result.is_ok());
1✔
704
    }
1✔
705

706
    #[test]
707
    fn test_validate_document_invalid_response_json() {
1✔
708
        let mut doc = create_test_document();
1✔
709
        doc.sections.push(Section {
1✔
710
            section_type: SectionType::Request,
1✔
711
            content: SectionContent::Json(serde_json::json!({"key": "value"})),
1✔
712
            inline_options: InlineOptions::default(),
1✔
713
            raw_content: "{\"key\": \"value\"}".to_string(),
1✔
714
            start_line: 5,
1✔
715
            end_line: 6,
1✔
716
        });
1✔
717
        doc.sections.push(Section {
1✔
718
            section_type: SectionType::Response,
1✔
719
            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1✔
720
            inline_options: InlineOptions::default(),
1✔
721
            raw_content: "{\"result\": \"ok\"}".to_string(),
1✔
722
            start_line: 7,
1✔
723
            end_line: 8,
1✔
724
        });
1✔
725

726
        let errors = validate_document_diagnostics(&doc);
1✔
727
        // Valid JSON should have no errors
728
        let has_json_errors = errors.iter().any(|e| e.message.contains("JSON"));
1✔
729
        assert!(!has_json_errors);
1✔
730
    }
1✔
731

732
    #[test]
733
    fn test_validate_error_details_must_be_array() {
1✔
734
        let mut doc = create_test_document();
1✔
735
        doc.sections.push(Section {
1✔
736
            section_type: SectionType::Error,
1✔
737
            content: SectionContent::Json(serde_json::json!({
1✔
738
                "code": 3,
1✔
739
                "details": {"@type": "type.googleapis.com/google.rpc.ErrorInfo"}
1✔
740
            })),
1✔
741
            inline_options: InlineOptions::default(),
1✔
742
            raw_content: "".to_string(),
1✔
743
            start_line: 5,
1✔
744
            end_line: 8,
1✔
745
        });
1✔
746

747
        let errors = validate_document_diagnostics(&doc);
1✔
748
        assert!(
1✔
749
            errors
1✔
750
                .iter()
1✔
751
                .any(|e| e.message.contains("field 'details' must be an array"))
1✔
752
        );
753
    }
1✔
754

755
    #[test]
756
    fn test_validate_error_details_items_must_be_objects() {
1✔
757
        let mut doc = create_test_document();
1✔
758
        doc.sections.push(Section {
1✔
759
            section_type: SectionType::Error,
1✔
760
            content: SectionContent::Json(serde_json::json!({
1✔
761
                "code": 3,
1✔
762
                "details": ["not-an-object"]
1✔
763
            })),
1✔
764
            inline_options: InlineOptions::default(),
1✔
765
            raw_content: "".to_string(),
1✔
766
            start_line: 5,
1✔
767
            end_line: 8,
1✔
768
        });
1✔
769

770
        let errors = validate_document_diagnostics(&doc);
1✔
771
        assert!(
1✔
772
            errors
1✔
773
                .iter()
1✔
774
                .any(|e| e.message.contains("'details' items must be objects"))
1✔
775
        );
776
    }
1✔
777

778
    #[test]
779
    fn test_validate_document_address_from_env() {
1✔
780
        // Set env var
781
        unsafe {
1✔
782
            std::env::set_var(crate::config::ENV_GRPCTESTIFY_ADDRESS, "env:5000");
1✔
783
        }
1✔
784

785
        let mut doc = GctfDocument::new("test.gctf".to_string());
1✔
786
        doc.sections.push(Section {
1✔
787
            section_type: SectionType::Endpoint,
1✔
788
            content: SectionContent::Single("Service/Method".to_string()),
1✔
789
            inline_options: InlineOptions::default(),
1✔
790
            raw_content: "Service/Method".to_string(),
1✔
791
            start_line: 1,
1✔
792
            end_line: 1,
1✔
793
        });
1✔
794
        doc.sections.push(Section {
1✔
795
            section_type: SectionType::Response,
1✔
796
            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1✔
797
            inline_options: InlineOptions::default(),
1✔
798
            raw_content: "{\"result\": \"ok\"}".to_string(),
1✔
799
            start_line: 2,
1✔
800
            end_line: 3,
1✔
801
        });
1✔
802

803
        let result = validate_document(&doc);
1✔
804
        // Should pass because address comes from env
805
        assert!(result.is_ok());
1✔
806

807
        // Clean up
808
        unsafe {
1✔
809
            std::env::remove_var(crate::config::ENV_GRPCTESTIFY_ADDRESS);
1✔
810
        }
1✔
811
    }
1✔
812

813
    #[test]
814
    fn test_validate_options_unknown_key_warning() {
1✔
815
        let mut doc = create_test_document();
1✔
816
        let mut options = std::collections::HashMap::new();
1✔
817
        options.insert("unknown".to_string(), "value".to_string());
1✔
818
        doc.sections.push(Section {
1✔
819
            section_type: SectionType::Options,
1✔
820
            content: SectionContent::KeyValues(options),
1✔
821
            inline_options: InlineOptions::default(),
1✔
822
            raw_content: "unknown: value".to_string(),
1✔
823
            start_line: 5,
1✔
824
            end_line: 6,
1✔
825
        });
1✔
826
        doc.sections.push(Section {
1✔
827
            section_type: SectionType::Response,
1✔
828
            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1✔
829
            inline_options: InlineOptions::default(),
1✔
830
            raw_content: "{\"result\": \"ok\"}".to_string(),
1✔
831
            start_line: 7,
1✔
832
            end_line: 8,
1✔
833
        });
1✔
834

835
        let diagnostics = validate_document_diagnostics(&doc);
1✔
836
        assert!(diagnostics.iter().any(|d| {
1✔
837
            d.severity == ErrorSeverity::Warning && d.message.contains("Unknown OPTIONS key")
1✔
838
        }));
1✔
839
    }
1✔
840

841
    #[test]
842
    fn test_validate_options_dry_run_is_unknown_key_warning() {
1✔
843
        let mut doc = create_test_document();
1✔
844
        let mut options = std::collections::HashMap::new();
1✔
845
        options.insert("dry_run".to_string(), "true".to_string());
1✔
846
        doc.sections.push(Section {
1✔
847
            section_type: SectionType::Options,
1✔
848
            content: SectionContent::KeyValues(options),
1✔
849
            inline_options: InlineOptions::default(),
1✔
850
            raw_content: "dry_run: true".to_string(),
1✔
851
            start_line: 5,
1✔
852
            end_line: 6,
1✔
853
        });
1✔
854
        doc.sections.push(Section {
1✔
855
            section_type: SectionType::Response,
1✔
856
            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1✔
857
            inline_options: InlineOptions::default(),
1✔
858
            raw_content: "{\"result\": \"ok\"}".to_string(),
1✔
859
            start_line: 7,
1✔
860
            end_line: 8,
1✔
861
        });
1✔
862

863
        let diagnostics = validate_document_diagnostics(&doc);
1✔
864
        assert!(diagnostics.iter().any(|d| {
1✔
865
            d.severity == ErrorSeverity::Warning
1✔
866
                && d.message
1✔
867
                    .contains("Unknown OPTIONS key 'dry_run'. Supported keys: timeout, retry, retry-delay, no-retry, compression")
1✔
868
        }));
1✔
869
    }
1✔
870

871
    #[test]
872
    fn test_validate_options_timeout_invalid_error() {
1✔
873
        let mut doc = create_test_document();
1✔
874
        let mut options = std::collections::HashMap::new();
1✔
875
        options.insert("timeout".to_string(), "0".to_string());
1✔
876
        doc.sections.push(Section {
1✔
877
            section_type: SectionType::Options,
1✔
878
            content: SectionContent::KeyValues(options),
1✔
879
            inline_options: InlineOptions::default(),
1✔
880
            raw_content: "timeout: 0".to_string(),
1✔
881
            start_line: 5,
1✔
882
            end_line: 6,
1✔
883
        });
1✔
884
        doc.sections.push(Section {
1✔
885
            section_type: SectionType::Response,
1✔
886
            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1✔
887
            inline_options: InlineOptions::default(),
1✔
888
            raw_content: "{\"result\": \"ok\"}".to_string(),
1✔
889
            start_line: 7,
1✔
890
            end_line: 8,
1✔
891
        });
1✔
892

893
        let diagnostics = validate_document_diagnostics(&doc);
1✔
894
        assert!(diagnostics.iter().any(|d| {
1✔
895
            d.severity == ErrorSeverity::Error
1✔
896
                && d.message
1✔
897
                    .contains("OPTIONS.timeout must be a positive integer")
1✔
898
        }));
1✔
899
    }
1✔
900

901
    #[test]
902
    fn test_validate_options_kebab_case_keys_are_supported() {
1✔
903
        let mut doc = create_test_document();
1✔
904
        let mut options = std::collections::HashMap::new();
1✔
905
        options.insert("timeout".to_string(), "5".to_string());
1✔
906
        options.insert("retry".to_string(), "2".to_string());
1✔
907
        options.insert("retry-delay".to_string(), "0.5".to_string());
1✔
908
        options.insert("no-retry".to_string(), "false".to_string());
1✔
909
        options.insert("compression".to_string(), "gzip".to_string());
1✔
910
        doc.sections.push(Section {
1✔
911
            section_type: SectionType::Options,
1✔
912
            content: SectionContent::KeyValues(options),
1✔
913
            inline_options: InlineOptions::default(),
1✔
914
            raw_content: "".to_string(),
1✔
915
            start_line: 5,
1✔
916
            end_line: 8,
1✔
917
        });
1✔
918
        doc.sections.push(Section {
1✔
919
            section_type: SectionType::Response,
1✔
920
            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1✔
921
            inline_options: InlineOptions::default(),
1✔
922
            raw_content: "{\"result\": \"ok\"}".to_string(),
1✔
923
            start_line: 9,
1✔
924
            end_line: 10,
1✔
925
        });
1✔
926

927
        let diagnostics = validate_document_diagnostics(&doc);
1✔
928
        assert!(
1✔
929
            !diagnostics
1✔
930
                .iter()
1✔
931
                .any(|d| d.message.contains("Unknown OPTIONS key"))
1✔
932
        );
933
        assert!(
1✔
934
            !diagnostics
1✔
935
                .iter()
1✔
936
                .any(|d| d.severity == ErrorSeverity::Error)
1✔
937
        );
938
    }
1✔
939

940
    #[test]
941
    fn test_validate_options_compression_invalid_error() {
1✔
942
        let mut doc = create_test_document();
1✔
943
        let mut options = std::collections::HashMap::new();
1✔
944
        options.insert("compression".to_string(), "brotli".to_string());
1✔
945
        doc.sections.push(Section {
1✔
946
            section_type: SectionType::Options,
1✔
947
            content: SectionContent::KeyValues(options),
1✔
948
            inline_options: InlineOptions::default(),
1✔
949
            raw_content: "compression: brotli".to_string(),
1✔
950
            start_line: 5,
1✔
951
            end_line: 6,
1✔
952
        });
1✔
953
        doc.sections.push(Section {
1✔
954
            section_type: SectionType::Response,
1✔
955
            content: SectionContent::Json(serde_json::json!({"result": "ok"})),
1✔
956
            inline_options: InlineOptions::default(),
1✔
957
            raw_content: "{\"result\": \"ok\"}".to_string(),
1✔
958
            start_line: 7,
1✔
959
            end_line: 8,
1✔
960
        });
1✔
961

962
        let diagnostics = validate_document_diagnostics(&doc);
1✔
963
        assert!(diagnostics.iter().any(|d| {
1✔
964
            d.severity == ErrorSeverity::Error
1✔
965
                && d.message
1✔
966
                    .contains("OPTIONS.compression must be one of: none, gzip")
1✔
967
        }));
1✔
968
    }
1✔
969

970
    #[test]
971
    fn test_validation_error_debug() {
1✔
972
        let error = ValidationError {
1✔
973
            message: "test error".to_string(),
1✔
974
            line: Some(10),
1✔
975
            severity: ErrorSeverity::Error,
1✔
976
        };
1✔
977
        let debug_str = format!("{:?}", error);
1✔
978
        assert!(debug_str.contains("ValidationError"));
1✔
979
        assert!(debug_str.contains("test error"));
1✔
980
    }
1✔
981

982
    #[test]
983
    fn test_error_severity_serialize() {
1✔
984
        let error = ErrorSeverity::Error;
1✔
985
        let json = serde_json::to_string(&error).unwrap();
1✔
986
        assert_eq!(json, "\"error\"");
1✔
987

988
        let warning = ErrorSeverity::Warning;
1✔
989
        let json = serde_json::to_string(&warning).unwrap();
1✔
990
        assert_eq!(json, "\"warning\"");
1✔
991
    }
1✔
992
}
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