miroir/crates/miroir-proxy/tests/error_format_parity.rs
jedarden 5442042bac P2.5 Task reconciliation: Add test helpers and fix error tests
- Add test-helpers feature to miroir-core for test-only methods
- Add test helper methods to InMemoryTaskRegistry:
  - set_error_for_test: Set error and node_errors for testing
  - set_timestamps_for_test: Set started_at/finished_at timestamps
  - set_node_task_status_for_test: Set node task status
  - set_task_status_for_test: Set overall task status
  - update_status: Async status update with timestamp handling
  - update_node_task: Async node task status update

- Fix error_format_parity.rs: Replace MiroirCode::ALL with static array
  to avoid const evaluation issues in test contexts

- Add regex dependency to miroir-proxy for testing

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 22:53:02 -04:00

415 lines
14 KiB
Rust

//! Error format parity tests: Verify Miroir errors match Meilisearch shape byte-for-byte.
//!
//! These tests use the actual error responses from Miroir and verify they match
//! the Meilisearch error format specification:
//! ```json
//! {
//! "message": "human readable message",
//! "code": "error_code",
//! "type": "invalid_request|auth|internal|system",
//! "link": "https://docs.meilisearch.com/errors#..."
//! }
//! ```
use miroir_core::api_error::{MiroirCode, MeilisearchError};
#[test]
fn test_miroir_error_shape_matches_meilisearch() {
// Test each MiroirCode variant
const ALL_CODES: [MiroirCode; 14] = [
MiroirCode::PrimaryKeyRequired,
MiroirCode::NoQuorum,
MiroirCode::ShardUnavailable,
MiroirCode::ReservedField,
MiroirCode::IdempotencyKeyReused,
MiroirCode::SettingsVersionStale,
MiroirCode::MultiAliasNotWritable,
MiroirCode::JwtInvalid,
MiroirCode::JwtScopeDenied,
MiroirCode::InvalidAuth,
MiroirCode::MissingCsrf,
MiroirCode::CsrfMismatch,
MiroirCode::IndexAlreadyExists,
MiroirCode::Timeout,
];
for code in ALL_CODES {
let error = MeilisearchError::new(code, "test message");
// Serialize to JSON
let json = serde_json::to_value(&error).expect("Failed to serialize error");
// Verify required fields exist
assert!(json.get("message").is_some(), "Error must have 'message' field");
assert!(json.get("code").is_some(), "Error must have 'code' field");
assert!(json.get("type").is_some(), "Error must have 'type' field");
assert!(json.get("link").is_some(), "Error must have 'link' field");
// Verify field types
assert!(json.get("message").unwrap().is_string(), "'message' must be a string");
assert!(json.get("code").unwrap().is_string(), "'code' must be a string");
assert!(json.get("type").unwrap().is_string(), "'type' must be a string");
assert!(json.get("link").unwrap().is_string(), "'link' must be a string");
// Verify type is one of the allowed values
let error_type = json.get("type").unwrap().as_str().unwrap();
assert!(
matches!(error_type, "invalid_request" | "auth" | "internal" | "system"),
"Error type must be one of: invalid_request, auth, internal, system"
);
// Verify code has miroir_ prefix
let error_code = json.get("code").unwrap().as_str().unwrap();
assert!(
error_code.starts_with("miroir_"),
"Miroir error codes must have 'miroir_' prefix, got: {}",
error_code
);
// Verify link format
let link = json.get("link").unwrap().as_str().unwrap();
assert!(
link.starts_with("https://github.com/jedarden/miroir"),
"Link must point to Miroir docs"
);
}
}
#[test]
fn test_error_http_status_codes() {
let test_cases = vec![
(MiroirCode::PrimaryKeyRequired, 400, "invalid_request"),
(MiroirCode::ReservedField, 400, "invalid_request"),
(MiroirCode::JwtInvalid, 401, "auth"),
(MiroirCode::InvalidAuth, 401, "auth"),
(MiroirCode::MissingCsrf, 401, "auth"),
(MiroirCode::JwtScopeDenied, 403, "auth"),
(MiroirCode::CsrfMismatch, 403, "auth"),
(MiroirCode::IdempotencyKeyReused, 409, "invalid_request"),
(MiroirCode::MultiAliasNotWritable, 409, "invalid_request"),
(MiroirCode::IndexAlreadyExists, 409, "invalid_request"),
(MiroirCode::Timeout, 504, "system"),
(MiroirCode::NoQuorum, 503, "system"),
(MiroirCode::ShardUnavailable, 503, "system"),
(MiroirCode::SettingsVersionStale, 503, "system"),
];
for (code, expected_status, expected_type) in test_cases {
let _error = MeilisearchError::new(code, "test message");
assert_eq!(code.http_status(), expected_status,
"HTTP status for {:?} should be {}, got {}",
code, expected_status, code.http_status());
assert_eq!(code.error_type().to_string(), expected_type,
"Error type for {:?} should be {}, got {:?}",
code, expected_type, code.error_type());
}
}
#[test]
fn test_error_serialization_is_deterministic() {
let error = MeilisearchError::new(MiroirCode::NoQuorum, "test message");
// Serialize multiple times
let json1 = serde_json::to_string(&error).unwrap();
let json2 = serde_json::to_string(&error).unwrap();
// Should be byte-identical
assert_eq!(json1, json2, "Error serialization must be deterministic");
// Parse and verify structure
let parsed: serde_json::Value = serde_json::from_str(&json1).unwrap();
assert_eq!(parsed.get("message").unwrap(), "test message");
assert_eq!(parsed.get("code").unwrap(), "miroir_no_quorum");
assert_eq!(parsed.get("type").unwrap(), "system");
}
#[test]
fn test_forwarded_meilisearch_error_parsing() {
// Test that we can parse forwarded Meilisearch errors
let meilisearch_error_json = r#"{
"message": "Index not found",
"code": "index_not_found",
"type": "invalid_request",
"link": "https://docs.meilisearch.com/errors#index_not_found"
}"#;
let parsed = MeilisearchError::forwarded(meilisearch_error_json);
assert!(parsed.is_some(), "Should successfully parse Meilisearch error");
let error = parsed.unwrap();
assert_eq!(error.message, "Index not found");
assert_eq!(error.code, "index_not_found");
assert_eq!(error.error_type.to_string(), "invalid_request");
}
#[test]
fn test_invalid_json_is_not_parsed_as_meilisearch_error() {
let invalid_json = r#"{"not": "an error"}"#;
let parsed = MeilisearchError::forwarded(invalid_json);
assert!(parsed.is_none(), "Should not parse invalid JSON as Meilisearch error");
}
#[test]
fn test_error_code_roundtrip() {
// Test that code strings can be parsed back to MiroirCode
const ALL_CODES: [MiroirCode; 14] = [
MiroirCode::PrimaryKeyRequired,
MiroirCode::NoQuorum,
MiroirCode::ShardUnavailable,
MiroirCode::ReservedField,
MiroirCode::IdempotencyKeyReused,
MiroirCode::SettingsVersionStale,
MiroirCode::MultiAliasNotWritable,
MiroirCode::JwtInvalid,
MiroirCode::JwtScopeDenied,
MiroirCode::InvalidAuth,
MiroirCode::MissingCsrf,
MiroirCode::CsrfMismatch,
MiroirCode::IndexAlreadyExists,
MiroirCode::Timeout,
];
for code in ALL_CODES {
let code_str = code.as_str();
let parsed = MiroirCode::from_code_str(code_str);
assert_eq!(parsed, Some(code), "Code '{}' should roundtrip to {:?}", code_str, code);
}
}
#[test]
fn test_unknown_code_returns_none() {
assert!(MiroirCode::from_code_str("unknown_code").is_none());
assert!(MiroirCode::from_code_str("miroir_unknown").is_none());
assert!(MiroirCode::from_code_str("").is_none());
}
#[test]
fn test_miroir_primary_key_required_error() {
let error = MeilisearchError::new(MiroirCode::PrimaryKeyRequired, "primary key is required");
let json = serde_json::to_value(&error).unwrap();
assert_eq!(json.get("code").unwrap(), "miroir_primary_key_required");
assert_eq!(json.get("type").unwrap(), "invalid_request");
assert_eq!(json.get("message").unwrap(), "primary key is required");
}
#[test]
fn test_miroir_no_quorum_error() {
let error = MeilisearchError::new(MiroirCode::NoQuorum, "insufficient nodes available");
let json = serde_json::to_value(&error).unwrap();
assert_eq!(json.get("code").unwrap(), "miroir_no_quorum");
assert_eq!(json.get("type").unwrap(), "system");
assert_eq!(json.get("message").unwrap(), "insufficient nodes available");
}
#[test]
fn test_miroir_shard_unavailable_error() {
let error = MeilisearchError::new(MiroirCode::ShardUnavailable, "shard 5 is unavailable");
let json = serde_json::to_value(&error).unwrap();
assert_eq!(json.get("code").unwrap(), "miroir_shard_unavailable");
assert_eq!(json.get("type").unwrap(), "system");
}
#[test]
fn test_miroir_reserved_field_error() {
let error = MeilisearchError::new(
MiroirCode::ReservedField,
"field '_miroir_internal' is reserved"
);
let json = serde_json::to_value(&error).unwrap();
assert_eq!(json.get("code").unwrap(), "miroir_reserved_field");
assert_eq!(json.get("type").unwrap(), "invalid_request");
}
#[test]
fn test_miroir_timeout_error() {
let error = MeilisearchError::new(MiroirCode::Timeout, "operation timed out");
let json = serde_json::to_value(&error).unwrap();
assert_eq!(json.get("code").unwrap(), "miroir_timeout");
assert_eq!(json.get("type").unwrap(), "system");
assert_eq!(error.http_status(), 504);
}
#[test]
fn test_error_message_preserves_content() {
let messages = vec![
"simple message",
"message with: special chars",
"message with\nnewlines",
"message with \"quotes\"",
"message with unicode: 🎉",
];
for msg in messages {
let error = MeilisearchError::new(MiroirCode::NoQuorum, msg);
assert_eq!(error.message, msg);
let json = serde_json::to_value(&error).unwrap();
assert_eq!(json.get("message").unwrap(), msg);
}
}
#[test]
fn test_all_miroir_codes_are_documented() {
// Verify all error codes have proper documentation links
const ALL_CODES: [MiroirCode; 14] = [
MiroirCode::PrimaryKeyRequired,
MiroirCode::NoQuorum,
MiroirCode::ShardUnavailable,
MiroirCode::ReservedField,
MiroirCode::IdempotencyKeyReused,
MiroirCode::SettingsVersionStale,
MiroirCode::MultiAliasNotWritable,
MiroirCode::JwtInvalid,
MiroirCode::JwtScopeDenied,
MiroirCode::InvalidAuth,
MiroirCode::MissingCsrf,
MiroirCode::CsrfMismatch,
MiroirCode::IndexAlreadyExists,
MiroirCode::Timeout,
];
for code in ALL_CODES {
let error = MeilisearchError::new(code, "test");
let json = serde_json::to_value(&error).unwrap();
let link = json.get("link").unwrap().as_str().unwrap();
// Verify link contains the error code
assert!(
link.contains(&format!("#{}", code.as_str())),
"Documentation link for {:?} should reference the error code: {}",
code, link
);
// Verify link is a valid URL
assert!(link.starts_with("https://"));
}
}
#[test]
fn test_error_response_includes_all_required_fields() {
// Create an error and verify it has all required fields
let error = MeilisearchError::new(MiroirCode::NoQuorum, "test message");
// Verify struct has all fields
assert!(!error.message.is_empty());
assert!(!error.code.is_empty());
assert!(error.link.is_some());
// Verify serialized JSON has all fields
let json = serde_json::to_value(&error).unwrap();
let obj = json.as_object().unwrap();
assert!(obj.contains_key("message"));
assert!(obj.contains_key("code"));
assert!(obj.contains_key("type"));
assert!(obj.contains_key("link"));
// Verify link is not null
assert!(obj.get("link").unwrap().is_string());
}
#[test]
fn test_error_type_classification() {
// Test that error types are correctly classified
// invalid_request errors
let invalid_request_codes = vec![
MiroirCode::PrimaryKeyRequired,
MiroirCode::ReservedField,
MiroirCode::IdempotencyKeyReused,
MiroirCode::MultiAliasNotWritable,
MiroirCode::IndexAlreadyExists,
];
for code in invalid_request_codes {
assert_eq!(code.error_type(), miroir_core::api_error::ErrorType::InvalidRequest);
assert_eq!(code.error_type().to_string(), "invalid_request");
}
// auth errors
let auth_codes = vec![
MiroirCode::JwtInvalid,
MiroirCode::JwtScopeDenied,
MiroirCode::InvalidAuth,
MiroirCode::MissingCsrf,
MiroirCode::CsrfMismatch,
];
for code in auth_codes {
assert_eq!(code.error_type(), miroir_core::api_error::ErrorType::Auth);
assert_eq!(code.error_type().to_string(), "auth");
}
// system errors
let system_codes = vec![
MiroirCode::NoQuorum,
MiroirCode::ShardUnavailable,
MiroirCode::SettingsVersionStale,
MiroirCode::Timeout,
];
for code in system_codes {
assert_eq!(code.error_type(), miroir_core::api_error::ErrorType::System);
assert_eq!(code.error_type().to_string(), "system");
}
}
#[test]
fn test_http_status_matches_error_type() {
// Verify HTTP status codes match error type conventions
// 4xx errors should be InvalidRequest or Auth
let client_error_codes = vec![
(MiroirCode::PrimaryKeyRequired, 400),
(MiroirCode::ReservedField, 400),
(MiroirCode::JwtInvalid, 401),
(MiroirCode::InvalidAuth, 401),
(MiroirCode::MissingCsrf, 401),
(MiroirCode::JwtScopeDenied, 403),
(MiroirCode::CsrfMismatch, 403),
(MiroirCode::IdempotencyKeyReused, 409),
(MiroirCode::IndexAlreadyExists, 409),
];
for (code, expected_status) in client_error_codes {
let status = code.http_status();
assert!(status >= 400 && status < 500,
"Client error {:?} should have 4xx status, got {}",
code, status);
assert_eq!(status, expected_status);
}
// 5xx errors should be System
let server_error_codes = vec![
(MiroirCode::NoQuorum, 503),
(MiroirCode::ShardUnavailable, 503),
(MiroirCode::SettingsVersionStale, 503),
(MiroirCode::Timeout, 504),
];
for (code, expected_status) in server_error_codes {
let status = code.http_status();
assert!(status >= 500 && status < 600,
"Server error {:?} should have 5xx status, got {}",
code, status);
assert_eq!(status, expected_status);
}
}