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

gripmock / grpctestify-rust / 29780499551

20 Jul 2026 09:31PM UTC coverage: 74.193% (+4.4%) from 69.813%
29780499551

Pull #70

github

web-flow
Merge 8a1565b6d into 617b201e5
Pull Request #70: refactoring & bug fixes

5906 of 7474 new or added lines in 69 files covered. (79.02%)

86 existing lines in 23 files now uncovered.

37388 of 50393 relevant lines covered (74.19%)

26807.31 hits per line

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

82.07
/src/execution/error_handler.rs
1
// Error handling for test execution
2

3
use apif_grpc_transport::GrpcError;
4
use prost::Message;
5
use prost_types::Any;
6
use serde_json::{Map, Value, json};
7

8
#[derive(Clone, PartialEq, Message)]
9
struct GoogleRpcStatus {
10
    #[prost(int32, tag = "1")]
11
    code: i32,
12
    #[prost(string, tag = "2")]
13
    message: String,
14
    #[prost(message, repeated, tag = "3")]
15
    details: Vec<Any>,
16
}
17

18
#[derive(Clone, PartialEq, Message)]
19
struct GoogleRpcErrorInfo {
20
    #[prost(string, tag = "1")]
21
    reason: String,
22
    #[prost(string, tag = "2")]
23
    domain: String,
24
    #[prost(map = "string, string", tag = "3")]
25
    metadata: std::collections::HashMap<String, String>,
26
}
27

28
#[derive(Clone, PartialEq, Message)]
29
struct GoogleRpcBadRequest {
30
    #[prost(message, repeated, tag = "1")]
31
    field_violations: Vec<GoogleRpcBadRequestFieldViolation>,
32
}
33

34
#[derive(Clone, PartialEq, Message)]
35
struct GoogleRpcBadRequestFieldViolation {
36
    #[prost(string, tag = "1")]
37
    field: String,
38
    #[prost(string, tag = "2")]
39
    description: String,
40
}
41

42
/// Error handler for gRPC test execution
43
pub struct ErrorHandler;
44

45
/// Normalize field names in `actual` to match `expected` key naming (camelCase ↔ snake_case).
46
fn normalize_field_names(
2✔
47
    actual: &Map<String, Value>,
2✔
48
    expected: &Map<String, Value>,
2✔
49
) -> Map<String, Value> {
2✔
50
    let mut result = actual.clone();
2✔
51
    for (exp_key, exp_val) in expected {
4✔
52
        if !result.contains_key(exp_key) {
4✔
53
            // Try to find a key that differs only by underscores/case
54
            let normalized_key = result
×
55
                .keys()
×
56
                .find(|k| {
×
57
                    let k_norm = k.replace('_', "").to_lowercase();
×
58
                    let exp_norm = exp_key.replace('_', "").to_lowercase();
×
59
                    k_norm == exp_norm && *k != exp_key.as_str()
×
60
                })
×
61
                .cloned();
×
62
            if let Some(found) = normalized_key {
×
63
                let val = result.remove(&found).unwrap_or(Value::Null);
×
UNCOV
64
                let val = match (exp_val, val.clone()) {
×
65
                    (Value::Object(exp_obj), Value::Object(act_obj)) => {
×
66
                        Value::Object(normalize_field_names(&act_obj, exp_obj))
×
67
                    }
68
                    _ => val,
×
69
                };
70
                result.insert(exp_key.clone(), val);
×
71
            }
×
72
        }
4✔
73
    }
74
    result
2✔
75
}
2✔
76

77
impl ErrorHandler {
78
    /// Check if error matches expected error
79
    pub fn error_matches_expected(error_text: &str, expected: &Value) -> bool {
11✔
80
        Self::message_matches_error_text(error_text, expected)
11✔
81
            && Self::code_matches_error_text(error_text, expected)
9✔
82
            && expected.get("details").is_none()
7✔
83
    }
11✔
84

85
    /// Check if GrpcError matches expected error JSON (supports details)
86
    pub fn status_matches_expected(status: &GrpcError, expected: &Value) -> bool {
7✔
87
        Self::status_matches_expected_with_options(status, expected, false)
7✔
88
    }
7✔
89

90
    /// Check if GrpcError matches expected error JSON, optionally as partial subset.
91
    pub fn status_matches_expected_with_options(
13✔
92
        status: &GrpcError,
13✔
93
        expected: &Value,
13✔
94
        partial: bool,
13✔
95
    ) -> bool {
13✔
96
        if !partial && !Self::status_has_no_unexpected_top_level_fields(status, expected) {
13✔
97
            return false;
3✔
98
        }
10✔
99

100
        if partial {
10✔
101
            let actual = Self::status_to_json(status);
4✔
102
            return Self::is_subset_match(&actual, expected);
4✔
103
        }
6✔
104

105
        if !Self::message_matches_status(status, expected)
6✔
106
            || !Self::code_matches_status(status, expected)
5✔
107
        {
108
            return false;
1✔
109
        }
5✔
110

111
        let expects_details = expected.get("details").is_some();
5✔
112
        if !expects_details {
5✔
113
            return status.details().is_empty()
2✔
114
                || status.details() == b"[]"
×
115
                || status.details() == b"{}";
×
116
        }
3✔
117

118
        let actual_details = match Self::decode_status_details(status.details()) {
3✔
119
            Some(d) => d,
3✔
120
            None => match Self::decode_json_details(status.details()) {
×
121
                Some(d) => d,
×
122
                None => return false,
×
123
            },
124
        };
125

126
        Self::compare_details(expected, actual_details).is_none()
3✔
127
    }
13✔
128

129
    /// Convert GrpcError details to JSON array
130
    pub fn status_details_json(status: &GrpcError) -> Value {
2✔
131
        match Self::decode_status_details(status.details()) {
2✔
132
            Some(details) => Value::Array(details),
2✔
133
            None => Value::Null,
×
134
        }
135
    }
2✔
136

137
    /// Convert GrpcError to JSON object for diff output
138
    pub fn status_to_json(status: &GrpcError) -> Value {
20✔
139
        let mut obj = Map::with_capacity(3);
20✔
140
        obj.insert("code".into(), Value::from(status.code() as i64));
20✔
141
        obj.insert("message".into(), Value::from(status.message()));
20✔
142

143
        if let Some(details) = Self::decode_status_details(status.details())
20✔
144
            && !details.is_empty()
20✔
145
        {
11✔
146
            obj.insert("details".into(), Value::Array(details));
11✔
147
        } else if let Some(details) = Self::decode_json_details(status.details())
11✔
148
            && !details.is_empty()
9✔
149
        {
×
150
            obj.insert("details".into(), Value::Array(details));
×
151
        }
9✔
152

153
        Value::Object(obj)
20✔
154
    }
20✔
155

156
    /// Try to decode details as raw JSON array bytes (ConnectRPC style).
157
    fn decode_json_details(raw: &[u8]) -> Option<Vec<Value>> {
9✔
158
        if raw.is_empty() {
9✔
159
            return Some(Vec::new());
9✔
160
        }
×
161
        serde_json::from_slice::<Vec<Value>>(raw).ok()
×
162
    }
9✔
163

164
    /// Returns a human-readable mismatch reason for GrpcError comparison
165
    pub fn status_mismatch_reason(status: &GrpcError, expected: &Value) -> Option<String> {
4✔
166
        Self::status_mismatch_reason_with_options(status, expected, false)
4✔
167
    }
4✔
168

169
    /// Returns mismatch reason for GrpcError comparison with optional partial mode.
170
    pub fn status_mismatch_reason_with_options(
6✔
171
        status: &GrpcError,
6✔
172
        expected: &Value,
6✔
173
        partial: bool,
6✔
174
    ) -> Option<String> {
6✔
175
        if !partial
6✔
176
            && let Some(reason) = Self::status_unexpected_top_level_field_reason(status, expected)
5✔
177
        {
178
            return Some(reason);
2✔
179
        }
4✔
180

181
        if partial {
4✔
182
            let actual = Self::status_to_json(status);
1✔
183
            return Self::subset_mismatch_reason(&actual, expected, "$");
1✔
184
        }
3✔
185

186
        if !Self::message_matches_status(status, expected) {
3✔
187
            let actual = status.message();
1✔
188
            if let Some(expected_msg) = expected.get("message").and_then(|v| v.as_str()) {
1✔
189
                return Some(format!(
1✔
190
                    "message mismatch: expected '{}', got '{}'",
1✔
191
                    expected_msg, actual
1✔
192
                ));
1✔
193
            }
×
194
            if expected.is_string()
×
195
                && let Some(s) = expected.as_str()
×
196
            {
197
                return Some(format!(
×
198
                    "message mismatch: expected '{}', got '{}'",
×
199
                    s, actual
×
200
                ));
×
201
            }
×
202
            return Some("message mismatch".to_string());
×
203
        }
2✔
204

205
        if !Self::code_matches_status(status, expected) {
2✔
206
            if let Some(code) = expected.get("code").and_then(|v| v.as_i64()) {
×
207
                let expected_name = Self::grpc_code_name_from_numeric(code).unwrap_or("Unknown");
×
208
                return Some(format!(
×
209
                    "code mismatch: expected {} ({}), got {} ({:?})",
×
210
                    code,
×
211
                    expected_name,
×
212
                    status.code() as i64,
×
213
                    status.code()
×
214
                ));
×
215
            }
×
216
            return Some("code mismatch".to_string());
×
217
        }
2✔
218

219
        let actual_details = match Self::decode_status_details(status.details()) {
2✔
220
            Some(details) => details,
2✔
221
            None => match Self::decode_json_details(status.details()) {
×
222
                Some(details) => details,
×
223
                None => return Some("cannot decode gRPC status details".to_string()),
×
224
            },
225
        };
226

227
        Self::compare_details(expected, actual_details)
2✔
228
    }
6✔
229

230
    fn is_subset_match(actual: &Value, expected: &Value) -> bool {
4✔
231
        Self::subset_mismatch_reason(actual, expected, "$").is_none()
4✔
232
    }
4✔
233

234
    fn status_has_no_unexpected_top_level_fields(status: &GrpcError, expected: &Value) -> bool {
9✔
235
        Self::status_unexpected_top_level_field_reason(status, expected).is_none()
9✔
236
    }
9✔
237

238
    fn status_unexpected_top_level_field_reason(
14✔
239
        status: &GrpcError,
14✔
240
        expected: &Value,
14✔
241
    ) -> Option<String> {
14✔
242
        let Value::Object(expected_obj) = expected else {
14✔
243
            return None;
×
244
        };
245
        let Value::Object(actual_obj) = Self::status_to_json(status) else {
14✔
246
            return None;
×
247
        };
248

249
        for key in actual_obj.keys() {
30✔
250
            if key == "details" && !expected_obj.contains_key("details") {
30✔
251
                return Some(format!(
2✔
252
                    "backend returned details, but ERROR.details is missing in gctf; actual details: {}",
2✔
253
                    Self::status_details_json(status)
2✔
254
                ));
2✔
255
            }
28✔
256
            if !expected_obj.contains_key(key) {
28✔
257
                return Some(format!(
3✔
258
                    "unexpected field in actual error: '{}' is present but missing in ERROR section (use partial=true or ASSERTS)",
3✔
259
                    key
3✔
260
                ));
3✔
261
            }
25✔
262
        }
263

264
        None
9✔
265
    }
14✔
266

267
    fn subset_mismatch_reason(actual: &Value, expected: &Value, path: &str) -> Option<String> {
24✔
268
        match (actual, expected) {
24✔
269
            (Value::Object(act_map), Value::Object(exp_map)) => {
10✔
270
                for (k, exp_val) in exp_map {
14✔
271
                    let key_path = format!("{}.{}", path, k);
14✔
272
                    let Some(act_val) = act_map.get(k) else {
14✔
273
                        return Some(format!("partial mismatch: missing key '{}'", key_path));
×
274
                    };
275
                    if let Some(reason) = Self::subset_mismatch_reason(act_val, exp_val, &key_path)
14✔
276
                    {
277
                        return Some(reason);
6✔
278
                    }
8✔
279
                }
280
                None
4✔
281
            }
282
            (Value::Array(act_arr), Value::Array(exp_arr)) => {
3✔
283
                let mut used_actual = vec![false; act_arr.len()];
3✔
284
                for (exp_idx, exp_item) in exp_arr.iter().enumerate() {
3✔
285
                    let mut matched = false;
3✔
286
                    for (act_idx, act_item) in act_arr.iter().enumerate() {
5✔
287
                        if used_actual[act_idx] {
5✔
288
                            continue;
×
289
                        }
5✔
290
                        if Self::subset_mismatch_reason(act_item, exp_item, path).is_none() {
5✔
291
                            used_actual[act_idx] = true;
1✔
292
                            matched = true;
1✔
293
                            break;
1✔
294
                        }
4✔
295
                    }
296

297
                    if !matched {
3✔
298
                        return Some(format!(
2✔
299
                            "partial mismatch: expected array item at '{}[{}]' not found",
2✔
300
                            path, exp_idx
2✔
301
                        ));
2✔
302
                    }
1✔
303
                }
304
                None
1✔
305
            }
306
            _ => {
307
                if actual == expected {
11✔
308
                    None
7✔
309
                } else {
310
                    Some(format!(
4✔
311
                        "partial mismatch at '{}': expected {}, got {}",
4✔
312
                        path, expected, actual
4✔
313
                    ))
4✔
314
                }
315
            }
316
        }
317
    }
24✔
318

319
    fn message_matches_error_text(error_text: &str, expected: &Value) -> bool {
11✔
320
        if let Some(expected_msg) = expected.get("message").and_then(|v| v.as_str()) {
11✔
321
            if !error_text.contains(expected_msg) {
5✔
322
                return false;
2✔
323
            }
3✔
324
        } else if expected.is_string()
6✔
325
            && let Some(s) = expected.as_str()
2✔
326
            && !error_text.contains(s)
2✔
327
        {
328
            return false;
×
329
        }
6✔
330

331
        true
9✔
332
    }
11✔
333

334
    fn message_matches_status(status: &GrpcError, expected: &Value) -> bool {
9✔
335
        if let Some(expected_msg) = expected.get("message").and_then(|v| v.as_str()) {
9✔
336
            return status.message() == expected_msg;
9✔
337
        }
×
338

339
        if expected.is_string()
×
340
            && let Some(s) = expected.as_str()
×
341
        {
342
            return status.message() == s;
×
343
        }
×
344

345
        true
×
346
    }
9✔
347

348
    fn code_matches_error_text(error_text: &str, expected: &Value) -> bool {
9✔
349
        if let Some(code) = expected.get("code").and_then(|v| v.as_i64())
9✔
350
            && let Some(code_name) = Self::grpc_code_name_from_numeric(code)
6✔
351
        {
352
            let status_marker = format!("status: {}", code_name);
6✔
353
            if !error_text.contains(&status_marker)
6✔
354
                && !error_text.contains(&format!("code: {}", code))
2✔
355
            {
356
                return false;
2✔
357
            }
4✔
358
        }
3✔
359

360
        true
7✔
361
    }
9✔
362

363
    fn code_matches_status(status: &GrpcError, expected: &Value) -> bool {
7✔
364
        if let Some(code) = expected.get("code").and_then(|v| v.as_i64()) {
7✔
365
            return status.code() as i64 == code;
7✔
366
        }
×
367
        true
×
368
    }
7✔
369

370
    fn compare_details(expected: &Value, actual_details: Vec<Value>) -> Option<String> {
5✔
371
        if let Some(expected_details) = expected.get("details") {
5✔
372
            let expected_array = match expected_details.as_array() {
5✔
373
                Some(array) => array,
5✔
374
                None => return Some("expected ERROR.details must be an array".to_string()),
×
375
            };
376

377
            if expected_array.len() != actual_details.len() {
5✔
378
                return Some(format!(
2✔
379
                    "details mismatch: expected {} item(s), got {}",
2✔
380
                    expected_array.len(),
2✔
381
                    actual_details.len()
2✔
382
                ));
2✔
383
            }
3✔
384

385
            for (idx, (exp, act)) in expected_array.iter().zip(actual_details.iter()).enumerate() {
6✔
386
                // Enrich actual with @type from expected if missing
387
                let enriched = if let (Value::Object(exp_obj), Value::Object(act_obj)) = (exp, act)
6✔
388
                    && exp_obj.contains_key("@type")
6✔
389
                    && !act_obj.contains_key("@type")
6✔
390
                {
391
                    let mut enriched = act_obj.clone();
×
392
                    if let Some(type_val) = exp_obj.get("@type") {
×
393
                        enriched.insert("@type".to_string(), type_val.clone());
×
394
                    }
×
395
                    Value::Object(enriched)
×
396
                } else {
397
                    act.clone()
6✔
398
                };
399

400
                if exp == &enriched {
6✔
401
                    continue;
4✔
402
                }
2✔
403

404
                // Try field-name normalization: snake_case ↔ camelCase
405
                if let (Value::Object(exp_obj), Value::Object(act_obj)) = (exp, &enriched) {
2✔
406
                    let norm_act = normalize_field_names(act_obj, exp_obj);
2✔
407
                    if exp == &Value::Object(norm_act) {
2✔
408
                        continue;
×
409
                    }
2✔
410
                }
×
411

412
                return Some(format!(
2✔
413
                    "details mismatch at index {}: expected {} but got {}",
2✔
414
                    idx, exp, act
2✔
415
                ));
2✔
416
            }
417

418
            return None;
1✔
419
        }
×
420

421
        if expected.is_object() && !actual_details.is_empty() {
×
422
            return Some(format!(
×
423
                "backend returned details, but ERROR.details is missing in gctf; actual details: {}",
×
424
                Value::Array(actual_details)
×
425
            ));
×
426
        }
×
427

428
        None
×
429
    }
5✔
430

431
    fn decode_status_details(raw: &[u8]) -> Option<Vec<Value>> {
27✔
432
        if raw.is_empty() {
27✔
433
            return Some(Vec::new());
11✔
434
        }
16✔
435

436
        let status = GoogleRpcStatus::decode(raw).ok()?;
16✔
437
        Some(status.details.into_iter().map(Self::any_to_json).collect())
16✔
438
    }
27✔
439

440
    fn any_to_json(any: Any) -> Value {
32✔
441
        let type_url = any.type_url;
32✔
442
        let value = any.value;
32✔
443

444
        if type_url.ends_with("/google.rpc.ErrorInfo") {
32✔
445
            if let Ok(info) = GoogleRpcErrorInfo::decode(value.as_slice()) {
16✔
446
                let mut metadata = Map::new();
16✔
447
                for (k, v) in info.metadata {
32✔
448
                    metadata.insert(k, Value::String(v));
32✔
449
                }
32✔
450

451
                return json!({
16✔
452
                    "@type": type_url,
16✔
453
                    "reason": info.reason,
16✔
454
                    "domain": info.domain,
16✔
455
                    "metadata": metadata,
16✔
456
                });
457
            }
×
458

459
            return json!({
×
460
                "@type": type_url,
×
461
                "_decodeError": "failed to decode ErrorInfo",
×
462
                "_valueHex": Self::hex_encode(value.as_slice()),
×
463
            });
464
        }
16✔
465

466
        if type_url.ends_with("/google.rpc.BadRequest") {
16✔
467
            if let Ok(bad_request) = GoogleRpcBadRequest::decode(value.as_slice()) {
16✔
468
                let field_violations = bad_request
16✔
469
                    .field_violations
16✔
470
                    .into_iter()
16✔
471
                    .map(|violation| {
16✔
472
                        json!({
16✔
473
                            "field": violation.field,
16✔
474
                            "description": violation.description,
16✔
475
                        })
476
                    })
16✔
477
                    .collect::<Vec<_>>();
16✔
478

479
                return json!({
16✔
480
                    "@type": type_url,
16✔
481
                    "fieldViolations": field_violations,
16✔
482
                });
483
            }
×
484

485
            return json!({
×
486
                "@type": type_url,
×
487
                "_decodeError": "failed to decode BadRequest",
×
488
                "_valueHex": Self::hex_encode(value.as_slice()),
×
489
            });
490
        }
×
491

492
        json!({
×
493
            "@type": type_url,
×
494
            "_valueHex": Self::hex_encode(value.as_slice()),
×
495
        })
496
    }
32✔
497

498
    fn hex_encode(bytes: &[u8]) -> String {
×
499
        const HEX: &[u8; 16] = b"0123456789abcdef";
500
        let mut output = String::with_capacity(bytes.len() * 2);
×
501
        for byte in bytes {
×
502
            output.push(HEX[(byte >> 4) as usize] as char);
×
503
            output.push(HEX[(byte & 0x0f) as usize] as char);
×
504
        }
×
505
        output
×
506
    }
×
507

508
    /// Get gRPC code name from numeric code
509
    pub fn grpc_code_name_from_numeric(code: i64) -> Option<&'static str> {
20✔
510
        match code {
20✔
511
            0 => Some("OK"),
3✔
512
            1 => Some("Cancelled"),
×
513
            2 => Some("Unknown"),
×
514
            3 => Some("InvalidArgument"),
3✔
515
            4 => Some("DeadlineExceeded"),
×
516
            5 => Some("NotFound"),
9✔
517
            6 => Some("AlreadyExists"),
×
518
            7 => Some("PermissionDenied"),
×
519
            8 => Some("ResourceExhausted"),
×
520
            9 => Some("FailedPrecondition"),
×
521
            10 => Some("Aborted"),
×
522
            11 => Some("OutOfRange"),
×
523
            12 => Some("Unimplemented"),
×
524
            13 => Some("Internal"),
2✔
525
            14 => Some("Unavailable"),
1✔
526
            15 => Some("DataLoss"),
×
527
            16 => Some("Unauthenticated"),
×
528
            _ => None,
2✔
529
        }
530
    }
20✔
531

532
    /// Format error message for display
533
    pub fn format_error_message(_error_text: &str, expected: &Value) -> String {
1✔
534
        let mut parts = Vec::new();
1✔
535

536
        if let Some(msg) = expected.get("message").and_then(|v| v.as_str()) {
1✔
537
            parts.push(format!("expected message: {}", msg));
1✔
538
        }
1✔
539

540
        if let Some(code) = expected.get("code").and_then(|v| v.as_i64()) {
1✔
541
            if let Some(code_name) = Self::grpc_code_name_from_numeric(code) {
1✔
542
                parts.push(format!("expected code: {} ({})", code, code_name));
1✔
543
            } else {
1✔
544
                parts.push(format!("expected code: {}", code));
×
545
            }
×
546
        }
×
547

548
        if expected.get("details").is_some() {
1✔
549
            parts.push("expected details".to_string());
×
550
        }
1✔
551

552
        if parts.is_empty() {
1✔
553
            "error expected".to_string()
×
554
        } else {
555
            parts.join(", ")
1✔
556
        }
557
    }
1✔
558

559
    /// Check if error text contains expected code
560
    pub fn error_contains_code(error_text: &str, expected_code: i64) -> bool {
2✔
561
        if let Some(code_name) = Self::grpc_code_name_from_numeric(expected_code) {
2✔
562
            error_text.contains(&format!("status: {}", code_name))
2✔
563
                || error_text.contains(&format!("code: {}", expected_code))
1✔
564
        } else {
565
            error_text.contains(&format!("code: {}", expected_code))
×
566
        }
567
    }
2✔
568

569
    /// Check if error text contains expected message
570
    pub fn error_contains_message(error_text: &str, expected_message: &str) -> bool {
2✔
571
        error_text.contains(expected_message)
2✔
572
    }
2✔
573
}
574

575
#[cfg(test)]
576
mod tests {
577
    use super::*;
578
    use prost::Message;
579
    use serde_json::json;
580
    fn status_with_details() -> GrpcError {
9✔
581
        let error_info = GoogleRpcErrorInfo {
9✔
582
            reason: "API_DISABLED".to_string(),
9✔
583
            domain: "your.service.com".to_string(),
9✔
584
            metadata: std::collections::HashMap::from([
9✔
585
                ("service".to_string(), "your.service.com".to_string()),
9✔
586
                ("consumer".to_string(), "projects/123".to_string()),
9✔
587
            ]),
9✔
588
        };
9✔
589

590
        let bad_request = GoogleRpcBadRequest {
9✔
591
            field_violations: vec![GoogleRpcBadRequestFieldViolation {
9✔
592
                field: "name".to_string(),
9✔
593
                description: "Name must be at least 3 characters".to_string(),
9✔
594
            }],
9✔
595
        };
9✔
596

597
        let status_proto = GoogleRpcStatus {
9✔
598
            code: 3, // InvalidArgument
9✔
599
            message: "Invalid argument provided".to_string(),
9✔
600
            details: vec![
9✔
601
                Any {
9✔
602
                    type_url: "type.googleapis.com/google.rpc.ErrorInfo".to_string(),
9✔
603
                    value: error_info.encode_to_vec(),
9✔
604
                },
9✔
605
                Any {
9✔
606
                    type_url: "type.googleapis.com/google.rpc.BadRequest".to_string(),
9✔
607
                    value: bad_request.encode_to_vec(),
9✔
608
                },
9✔
609
            ],
9✔
610
        };
9✔
611

612
        GrpcError::with_details(3, "Invalid argument provided", status_proto.encode_to_vec())
9✔
613
    }
9✔
614

615
    fn status_without_details() -> GrpcError {
7✔
616
        GrpcError::new(3, "Invalid argument provided")
7✔
617
    }
7✔
618

619
    #[test]
620
    fn test_error_matches_expected_message() {
1✔
621
        let error_text = "Error: status: NotFound, message: Resource not found";
1✔
622
        let expected = json!({"message": "Resource not found"});
1✔
623

624
        assert!(ErrorHandler::error_matches_expected(error_text, &expected));
1✔
625
    }
1✔
626

627
    #[test]
628
    fn test_error_matches_expected_code() {
1✔
629
        let error_text = "Error: status: NotFound, message: Resource not found";
1✔
630
        let expected = json!({"code": 5});
1✔
631

632
        assert!(ErrorHandler::error_matches_expected(error_text, &expected));
1✔
633
    }
1✔
634

635
    #[test]
636
    fn test_error_matches_expected_both() {
1✔
637
        let error_text = "Error: status: NotFound, message: Resource not found";
1✔
638
        let expected = json!({"code": 5, "message": "Resource not found"});
1✔
639

640
        assert!(ErrorHandler::error_matches_expected(error_text, &expected));
1✔
641
    }
1✔
642

643
    #[test]
644
    fn test_error_matches_expected_wrong_message() {
1✔
645
        let error_text = "Error: status: NotFound, message: Resource not found";
1✔
646
        let expected = json!({"message": "Wrong message"});
1✔
647

648
        assert!(!ErrorHandler::error_matches_expected(error_text, &expected));
1✔
649
    }
1✔
650

651
    #[test]
652
    fn test_error_matches_expected_wrong_code() {
1✔
653
        let error_text = "Error: status: NotFound, message: Resource not found";
1✔
654
        let expected = json!({"code": 3});
1✔
655

656
        assert!(!ErrorHandler::error_matches_expected(error_text, &expected));
1✔
657
    }
1✔
658

659
    #[test]
660
    fn test_grpc_code_name_from_numeric() {
1✔
661
        assert_eq!(ErrorHandler::grpc_code_name_from_numeric(0), Some("OK"));
1✔
662
        assert_eq!(
1✔
663
            ErrorHandler::grpc_code_name_from_numeric(5),
1✔
664
            Some("NotFound")
665
        );
666
        assert_eq!(
1✔
667
            ErrorHandler::grpc_code_name_from_numeric(13),
1✔
668
            Some("Internal")
669
        );
670
        assert_eq!(ErrorHandler::grpc_code_name_from_numeric(999), None);
1✔
671
    }
1✔
672

673
    #[test]
674
    fn test_format_error_message() {
1✔
675
        let expected = json!({"code": 5, "message": "Resource not found"});
1✔
676
        let formatted = ErrorHandler::format_error_message("", &expected);
1✔
677

678
        assert!(formatted.contains("expected message: Resource not found"));
1✔
679
        assert!(formatted.contains("expected code: 5 (NotFound)"));
1✔
680
    }
1✔
681

682
    #[test]
683
    fn test_error_contains_code() {
1✔
684
        let error_text = "Error: status: NotFound, code: 5";
1✔
685

686
        assert!(ErrorHandler::error_contains_code(error_text, 5));
1✔
687
        assert!(!ErrorHandler::error_contains_code(error_text, 3));
1✔
688
    }
1✔
689

690
    #[test]
691
    fn test_error_contains_message() {
1✔
692
        let error_text = "Error: Resource not found";
1✔
693

694
        assert!(ErrorHandler::error_contains_message(
1✔
695
            error_text,
1✔
696
            "Resource not found"
1✔
697
        ));
698
        assert!(!ErrorHandler::error_contains_message(
1✔
699
            error_text,
1✔
700
            "Wrong message"
1✔
701
        ));
1✔
702
    }
1✔
703

704
    #[test]
705
    fn test_error_matches_expected_string() {
1✔
706
        let error_text = "Error: Resource not found";
1✔
707
        let expected = json!("Resource not found");
1✔
708

709
        assert!(ErrorHandler::error_matches_expected(error_text, &expected));
1✔
710
    }
1✔
711

712
    #[test]
713
    fn test_status_matches_expected_with_details() {
1✔
714
        let status = status_with_details();
1✔
715
        let expected = json!({
1✔
716
            "code": 3,
1✔
717
            "message": "Invalid argument provided",
1✔
718
            "details": [
1✔
719
                {
720
                    "@type": "type.googleapis.com/google.rpc.ErrorInfo",
1✔
721
                    "reason": "API_DISABLED",
1✔
722
                    "domain": "your.service.com",
1✔
723
                    "metadata": {
1✔
724
                        "service": "your.service.com",
1✔
725
                        "consumer": "projects/123"
1✔
726
                    }
727
                },
728
                {
729
                    "@type": "type.googleapis.com/google.rpc.BadRequest",
1✔
730
                    "fieldViolations": [
1✔
731
                        {
732
                            "field": "name",
1✔
733
                            "description": "Name must be at least 3 characters"
1✔
734
                        }
735
                    ]
736
                }
737
            ]
738
        });
739

740
        assert!(ErrorHandler::status_matches_expected(&status, &expected));
1✔
741
    }
1✔
742

743
    #[test]
744
    fn test_status_matches_expected_with_wrong_details() {
1✔
745
        let status = status_with_details();
1✔
746
        let expected = json!({
1✔
747
            "details": [
1✔
748
                {
749
                    "@type": "type.googleapis.com/google.rpc.ErrorInfo",
1✔
750
                    "reason": "WRONG_REASON"
1✔
751
                }
752
            ]
753
        });
754

755
        assert!(!ErrorHandler::status_matches_expected(&status, &expected));
1✔
756
    }
1✔
757

758
    #[test]
759
    fn test_status_matches_expected_fails_when_actual_has_unexpected_details() {
1✔
760
        let status = status_with_details();
1✔
761
        let expected = json!({
1✔
762
            "code": 3,
1✔
763
            "message": "Invalid argument provided"
1✔
764
        });
765

766
        assert!(!ErrorHandler::status_matches_expected(&status, &expected));
1✔
767
    }
1✔
768

769
    #[test]
770
    fn test_status_matches_expected_fails_when_expected_requires_details() {
1✔
771
        let status = status_without_details();
1✔
772
        let expected = json!({
1✔
773
            "code": 3,
1✔
774
            "message": "Invalid argument provided",
1✔
775
            "details": [
1✔
776
                {
777
                    "@type": "type.googleapis.com/google.rpc.ErrorInfo",
1✔
778
                    "reason": "API_DISABLED"
1✔
779
                }
780
            ]
781
        });
782

783
        assert!(!ErrorHandler::status_matches_expected(&status, &expected));
1✔
784
    }
1✔
785

786
    #[test]
787
    fn test_status_matches_expected_passes_when_no_details_on_both_sides() {
1✔
788
        let status = status_without_details();
1✔
789
        let expected = json!({
1✔
790
            "code": 3,
1✔
791
            "message": "Invalid argument provided"
1✔
792
        });
793

794
        assert!(ErrorHandler::status_matches_expected(&status, &expected));
1✔
795
    }
1✔
796

797
    #[test]
798
    fn test_status_matches_expected_rejects_partial_message_match() {
1✔
799
        let status = status_without_details();
1✔
800
        let expected = json!({
1✔
801
            "code": 3,
1✔
802
            "message": "Invalid argument"
1✔
803
        });
804

805
        assert!(!ErrorHandler::status_matches_expected(&status, &expected));
1✔
806
        let reason = ErrorHandler::status_mismatch_reason(&status, &expected).unwrap();
1✔
807
        assert!(reason.contains("expected 'Invalid argument'"));
1✔
808
        assert!(reason.contains("got 'Invalid argument provided'"));
1✔
809
    }
1✔
810

811
    #[test]
812
    fn test_status_matches_expected_partial_allows_subset_top_level_fields() {
1✔
813
        let status = status_with_details();
1✔
814
        let expected = json!({
1✔
815
            "code": 3
1✔
816
        });
817

818
        assert!(ErrorHandler::status_matches_expected_with_options(
1✔
819
            &status, &expected, true
1✔
820
        ));
821
    }
1✔
822

823
    #[test]
824
    fn test_status_matches_expected_partial_allows_subset_details_payload() {
1✔
825
        let status = status_with_details();
1✔
826
        let expected = json!({
1✔
827
            "code": 3,
1✔
828
            "details": [
1✔
829
                {
830
                    "@type": "type.googleapis.com/google.rpc.ErrorInfo",
1✔
831
                    "reason": "API_DISABLED"
1✔
832
                }
833
            ]
834
        });
835

836
        assert!(ErrorHandler::status_matches_expected_with_options(
1✔
837
            &status, &expected, true
1✔
838
        ));
839
    }
1✔
840

841
    #[test]
842
    fn test_status_matches_expected_partial_fails_when_expected_detail_item_missing() {
1✔
843
        let status = status_with_details();
1✔
844
        let expected = json!({
1✔
845
            "code": 3,
1✔
846
            "details": [
1✔
847
                {
848
                    "@type": "type.googleapis.com/google.rpc.DebugInfo",
1✔
849
                    "detail": "not-present"
1✔
850
                }
851
            ]
852
        });
853

854
        assert!(!ErrorHandler::status_matches_expected_with_options(
1✔
855
            &status, &expected, true
1✔
856
        ));
1✔
857
        let reason = ErrorHandler::status_mismatch_reason_with_options(&status, &expected, true)
1✔
858
            .expect("partial mismatch reason");
1✔
859
        assert!(reason.contains("expected array item"));
1✔
860
    }
1✔
861

862
    #[test]
863
    fn test_status_matches_expected_strict_fails_when_message_omitted() {
1✔
864
        let status = status_without_details();
1✔
865
        let expected = json!({
1✔
866
            "code": 3
1✔
867
        });
868

869
        assert!(!ErrorHandler::status_matches_expected_with_options(
1✔
870
            &status, &expected, false
1✔
871
        ));
1✔
872
        let reason = ErrorHandler::status_mismatch_reason_with_options(&status, &expected, false)
1✔
873
            .expect("strict mismatch reason");
1✔
874
        assert!(reason.contains("unexpected field"));
1✔
875
        assert!(reason.contains("message"));
1✔
876
    }
1✔
877

878
    #[test]
879
    fn test_status_matches_expected_partial_passes_when_message_omitted() {
1✔
880
        let status = status_without_details();
1✔
881
        let expected = json!({
1✔
882
            "code": 3
1✔
883
        });
884

885
        assert!(ErrorHandler::status_matches_expected_with_options(
1✔
886
            &status, &expected, true
1✔
887
        ));
888
    }
1✔
889

890
    #[test]
891
    fn test_status_matches_expected_strict_allows_missing_details_when_not_present_in_actual() {
1✔
892
        let status = status_without_details();
1✔
893
        let expected = json!({
1✔
894
            "code": 3,
1✔
895
            "message": "Invalid argument provided"
1✔
896
        });
897

898
        assert!(ErrorHandler::status_matches_expected_with_options(
1✔
899
            &status, &expected, false
1✔
900
        ));
901
    }
1✔
902

903
    #[test]
904
    fn test_status_mismatch_reason_for_unexpected_details() {
1✔
905
        let status = status_with_details();
1✔
906
        let expected = json!({
1✔
907
            "code": 3,
1✔
908
            "message": "Invalid argument provided"
1✔
909
        });
910

911
        let reason = ErrorHandler::status_mismatch_reason(&status, &expected).unwrap();
1✔
912
        assert!(reason.contains("ERROR.details is missing"));
1✔
913
        assert!(reason.contains("actual details"));
1✔
914
        assert!(reason.contains("type.googleapis.com/google.rpc.ErrorInfo"));
1✔
915
    }
1✔
916

917
    #[test]
918
    fn test_status_mismatch_reason_for_missing_required_details() {
1✔
919
        let status = status_without_details();
1✔
920
        let expected = json!({
1✔
921
            "code": 3,
1✔
922
            "message": "Invalid argument provided",
1✔
923
            "details": [
1✔
924
                {"@type": "type.googleapis.com/google.rpc.ErrorInfo"}
1✔
925
            ]
926
        });
927

928
        let reason = ErrorHandler::status_mismatch_reason(&status, &expected).unwrap();
1✔
929
        assert!(reason.contains("details mismatch"));
1✔
930
    }
1✔
931

932
    #[test]
933
    fn test_status_to_json_contains_details() {
1✔
934
        let status = status_with_details();
1✔
935
        let json = ErrorHandler::status_to_json(&status);
1✔
936
        assert_eq!(json["code"], 3);
1✔
937
        assert_eq!(json["message"], "Invalid argument provided");
1✔
938
        assert!(json.get("details").is_some());
1✔
939
    }
1✔
940

941
    #[test]
942
    fn test_status_matches_expected_fails_when_details_field_is_missing_in_expected_object() {
1✔
943
        let status = status_with_details();
1✔
944
        let expected = json!({
1✔
945
            "code": 3,
1✔
946
            "message": "Invalid argument provided",
1✔
947
            "details": [
1✔
948
                {
949
                    "@type": "type.googleapis.com/google.rpc.ErrorInfo",
1✔
950
                    "reason": "API_DISABLED",
1✔
951
                    "domain": "your.service.com",
1✔
952
                    "metadata": {
1✔
953
                        "service": "your.service.com",
1✔
954
                        "consumer": "projects/123"
1✔
955
                    }
956
                },
957
                {
958
                    "@type": "type.googleapis.com/google.rpc.BadRequest",
1✔
959
                    "fieldViolations": [
1✔
960
                        {
961
                            "description": "Name must be at least 3 characters"
1✔
962
                        }
963
                    ]
964
                }
965
            ]
966
        });
967

968
        assert!(!ErrorHandler::status_matches_expected(&status, &expected));
1✔
969
        let reason = ErrorHandler::status_mismatch_reason(&status, &expected).unwrap();
1✔
970
        assert!(reason.contains("details mismatch at index 1"));
1✔
971
    }
1✔
972
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc