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

gripmock / grpctestify-rust / 29448344403

15 Jul 2026 08:27PM UTC coverage: 69.815% (-0.4%) from 70.25%
29448344403

Pull #65

github

web-flow
Merge bd842704b into a4aabffa2
Pull Request #65: connectrpc & grpc-web fix

66 of 474 new or added lines in 8 files covered. (13.92%)

5 existing lines in 2 files now uncovered.

30952 of 44334 relevant lines covered (69.82%)

22936.95 hits per line

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

81.91
/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
NEW
54
            let normalized_key = result
×
NEW
55
                .keys()
×
NEW
56
                .find(|k| {
×
NEW
57
                    let k_norm = k.replace('_', "").to_lowercase();
×
NEW
58
                    let exp_norm = exp_key.replace('_', "").to_lowercase();
×
NEW
59
                    k_norm == exp_norm && *k != exp_key.as_str()
×
NEW
60
                })
×
NEW
61
                .cloned();
×
NEW
62
            if let Some(found) = normalized_key {
×
NEW
63
                let val = result.remove(&found).unwrap_or(Value::Null);
×
64
                // Recurse for nested objects
NEW
65
                let val = match (exp_val, val.clone()) {
×
NEW
66
                    (Value::Object(exp_obj), Value::Object(act_obj)) => {
×
NEW
67
                        Value::Object(normalize_field_names(&act_obj, exp_obj))
×
68
                    }
NEW
69
                    _ => val,
×
70
                };
NEW
71
                result.insert(exp_key.clone(), val);
×
NEW
72
            }
×
73
        }
4✔
74
    }
75
    result
2✔
76
}
2✔
77

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

265
        None
9✔
266
    }
14✔
267

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

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

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

333
        true
9✔
334
    }
11✔
335

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

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

347
        true
×
348
    }
9✔
349

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

363
        true
7✔
364
    }
9✔
365

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

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

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

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

403
                if exp == &enriched {
6✔
404
                    continue;
4✔
405
                }
2✔
406

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

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

421
            return None;
1✔
422
        }
×
423

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

431
        None
×
432
    }
5✔
433

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

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

443
    fn any_to_json(any: Any) -> Value {
32✔
444
        let type_url = any.type_url;
32✔
445
        let value = any.value;
32✔
446

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

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

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

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

482
                return json!({
16✔
483
                    "@type": type_url,
16✔
484
                    "fieldViolations": field_violations,
16✔
485
                });
486
            }
×
487

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

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

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

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

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

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

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

551
        if expected.get("details").is_some() {
1✔
552
            parts.push("expected details".to_string());
×
553
        }
1✔
554

555
        if parts.is_empty() {
1✔
556
            "error expected".to_string()
×
557
        } else {
558
            parts.join(", ")
1✔
559
        }
560
    }
1✔
561

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

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

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

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

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

615
        GrpcError::with_details(3, "Invalid argument provided", status_proto.encode_to_vec())
9✔
616
    }
9✔
617

618
    fn status_without_details() -> GrpcError {
7✔
619
        GrpcError::new(3, "Invalid argument provided")
7✔
620
    }
7✔
621

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

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

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

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

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

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

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

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

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

659
        assert!(!ErrorHandler::error_matches_expected(error_text, &expected));
1✔
660
    }
1✔
661

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

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

681
        assert!(formatted.contains("expected message: Resource not found"));
1✔
682
        assert!(formatted.contains("expected code: 5 (NotFound)"));
1✔
683
    }
1✔
684

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

689
        assert!(ErrorHandler::error_contains_code(error_text, 5));
1✔
690
        assert!(!ErrorHandler::error_contains_code(error_text, 3));
1✔
691
    }
1✔
692

693
    #[test]
694
    fn test_error_contains_message() {
1✔
695
        let error_text = "Error: Resource not found";
1✔
696

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

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

712
        assert!(ErrorHandler::error_matches_expected(error_text, &expected));
1✔
713
    }
1✔
714

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

743
        assert!(ErrorHandler::status_matches_expected(&status, &expected));
1✔
744
    }
1✔
745

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

758
        assert!(!ErrorHandler::status_matches_expected(&status, &expected));
1✔
759
    }
1✔
760

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

769
        assert!(!ErrorHandler::status_matches_expected(&status, &expected));
1✔
770
    }
1✔
771

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

786
        assert!(!ErrorHandler::status_matches_expected(&status, &expected));
1✔
787
    }
1✔
788

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

797
        assert!(ErrorHandler::status_matches_expected(&status, &expected));
1✔
798
    }
1✔
799

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

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

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

821
        assert!(ErrorHandler::status_matches_expected_with_options(
1✔
822
            &status, &expected, true
1✔
823
        ));
824
    }
1✔
825

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

839
        assert!(ErrorHandler::status_matches_expected_with_options(
1✔
840
            &status, &expected, true
1✔
841
        ));
842
    }
1✔
843

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

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

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

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

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

888
        assert!(ErrorHandler::status_matches_expected_with_options(
1✔
889
            &status, &expected, true
1✔
890
        ));
891
    }
1✔
892

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

901
        assert!(ErrorHandler::status_matches_expected_with_options(
1✔
902
            &status, &expected, false
1✔
903
        ));
904
    }
1✔
905

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

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

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

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

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

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

971
        assert!(!ErrorHandler::status_matches_expected(&status, &expected));
1✔
972
        let reason = ErrorHandler::status_mismatch_reason(&status, &expected).unwrap();
1✔
973
        assert!(reason.contains("details mismatch at index 1"));
1✔
974
    }
1✔
975
}
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