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

gripmock / grpctestify-rust / 30248040547

27 Jul 2026 07:57AM UTC coverage: 80.574% (+6.4%) from 74.202%
30248040547

Pull #81

github

web-flow
Merge 55b8756ce into 67c08fb25
Pull Request #81: rhai

47513 of 58968 relevant lines covered (80.57%)

23229.44 hits per line

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

82.07
/src/execution/error_handler.rs
1
use apif_grpc_transport::GrpcError;
2
use prost::Message;
3
use prost_types::Any;
4
use serde_json::{Map, Value, json};
5

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

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

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

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

40
pub struct ErrorHandler;
41

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

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

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

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

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

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

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

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

123
        Self::compare_details(expected, actual_details).is_none()
3✔
124
    }
13✔
125

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

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

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

150
        Value::Object(obj)
20✔
151
    }
20✔
152

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

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

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

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

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

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

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

224
        Self::compare_details(expected, actual_details)
2✔
225
    }
6✔
226

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

231
    fn status_has_no_unexpected_top_level_fields(status: &GrpcError, expected: &Value) -> bool {
9✔
232
        Self::status_unexpected_top_level_field_reason(status, expected).is_none()
9✔
233
    }
9✔
234

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

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

261
        None
9✔
262
    }
14✔
263

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

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

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

328
        true
9✔
329
    }
11✔
330

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

336
        if expected.is_string()
×
337
            && let Some(s) = expected.as_str()
×
338
        {
339
            return status.message() == s;
×
340
        }
×
341

342
        true
×
343
    }
9✔
344

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

357
        true
7✔
358
    }
9✔
359

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

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

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

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

397
                if exp == &enriched {
6✔
398
                    continue;
4✔
399
                }
2✔
400

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

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

415
            return None;
1✔
416
        }
×
417

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

425
        None
×
426
    }
5✔
427

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

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

437
    fn any_to_json(any: Any) -> Value {
32✔
438
        let type_url = any.type_url;
32✔
439
        let value = any.value;
32✔
440

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

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

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

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

476
                return json!({
16✔
477
                    "@type": type_url,
16✔
478
                    "fieldViolations": field_violations,
16✔
479
                });
480
            }
×
481

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

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

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

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

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

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

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

545
        if expected.get("details").is_some() {
1✔
546
            parts.push("expected details".to_string());
×
547
        }
1✔
548

549
        if parts.is_empty() {
1✔
550
            "error expected".to_string()
×
551
        } else {
552
            parts.join(", ")
1✔
553
        }
554
    }
1✔
555

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

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

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

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

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

609
        GrpcError::with_details(3, "Invalid argument provided", status_proto.encode_to_vec())
9✔
610
    }
9✔
611

612
    fn status_without_details() -> GrpcError {
7✔
613
        GrpcError::new(3, "Invalid argument provided")
7✔
614
    }
7✔
615

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

621
        assert!(ErrorHandler::error_matches_expected(error_text, &expected));
1✔
622
    }
1✔
623

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

629
        assert!(ErrorHandler::error_matches_expected(error_text, &expected));
1✔
630
    }
1✔
631

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

637
        assert!(ErrorHandler::error_matches_expected(error_text, &expected));
1✔
638
    }
1✔
639

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

645
        assert!(!ErrorHandler::error_matches_expected(error_text, &expected));
1✔
646
    }
1✔
647

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

653
        assert!(!ErrorHandler::error_matches_expected(error_text, &expected));
1✔
654
    }
1✔
655

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

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

675
        assert!(formatted.contains("expected message: Resource not found"));
1✔
676
        assert!(formatted.contains("expected code: 5 (NotFound)"));
1✔
677
    }
1✔
678

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

683
        assert!(ErrorHandler::error_contains_code(error_text, 5));
1✔
684
        assert!(!ErrorHandler::error_contains_code(error_text, 3));
1✔
685
    }
1✔
686

687
    #[test]
688
    fn test_error_contains_message() {
1✔
689
        let error_text = "Error: Resource not found";
1✔
690

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

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

706
        assert!(ErrorHandler::error_matches_expected(error_text, &expected));
1✔
707
    }
1✔
708

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

737
        assert!(ErrorHandler::status_matches_expected(&status, &expected));
1✔
738
    }
1✔
739

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

752
        assert!(!ErrorHandler::status_matches_expected(&status, &expected));
1✔
753
    }
1✔
754

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

763
        assert!(!ErrorHandler::status_matches_expected(&status, &expected));
1✔
764
    }
1✔
765

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

780
        assert!(!ErrorHandler::status_matches_expected(&status, &expected));
1✔
781
    }
1✔
782

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

791
        assert!(ErrorHandler::status_matches_expected(&status, &expected));
1✔
792
    }
1✔
793

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

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

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

815
        assert!(ErrorHandler::status_matches_expected_with_options(
1✔
816
            &status, &expected, true
1✔
817
        ));
818
    }
1✔
819

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

833
        assert!(ErrorHandler::status_matches_expected_with_options(
1✔
834
            &status, &expected, true
1✔
835
        ));
836
    }
1✔
837

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

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

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

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

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

882
        assert!(ErrorHandler::status_matches_expected_with_options(
1✔
883
            &status, &expected, true
1✔
884
        ));
885
    }
1✔
886

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

895
        assert!(ErrorHandler::status_matches_expected_with_options(
1✔
896
            &status, &expected, false
1✔
897
        ));
898
    }
1✔
899

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

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

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

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

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

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

965
        assert!(!ErrorHandler::status_matches_expected(&status, &expected));
1✔
966
        let reason = ErrorHandler::status_mismatch_reason(&status, &expected).unwrap();
1✔
967
        assert!(reason.contains("details mismatch at index 1"));
1✔
968
    }
1✔
969
}
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