Fixes:
- Removed #[axum::debug_handler] from search_handler to fix Send trait issue
(EnteredSpan is not Send, causing compilation error)
- Updated p2_phase2_dod.rs tests to use new plan_search_scatter signature
(async function with additional replica_selector parameter)
- Removed unused imports
The P2.4 implementation was already complete in indexes.rs and keys.rs:
- POST /indexes creates index on every node with rollback on failure
- PATCH /indexes/{uid}/settings sequential broadcast with rollback
- DELETE /indexes/{uid} broadcasts to all nodes
- GET /indexes/{uid}/stats aggregates logical doc count (divided by RG*RF)
- POST/PATCH/DELETE /keys broadcasts with rollback
All tests pass:
- p24_index_lifecycle: 11/11 tests pass
- p2_phase2_dod: 14/14 tests pass
- miroir-proxy lib: 135/135 tests pass
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
661 lines
24 KiB
Rust
661 lines
24 KiB
Rust
//! Phase 2 DoD integration tests.
|
||
//!
|
||
//! Tests covering all Definition of Done criteria:
|
||
//! - 1000 documents indexed across 3 nodes, each retrievable by ID
|
||
//! - Unique-keyword search finds every doc exactly once
|
||
//! - Facet aggregation across 3 color values sums correctly
|
||
//! - Offset/limit paging preserves global ordering
|
||
//! - Write with one group completely down still succeeds + X-Miroir-Degraded
|
||
//! - Error-format parity: every error matches Meilisearch shape
|
||
//! - GET /_miroir/topology matches plan §10 shape
|
||
|
||
use miroir_core::api_error::{ErrorType, MeilisearchError, MiroirCode};
|
||
use miroir_core::merger::ScoreMergeStrategy;
|
||
use miroir_core::router::{shard_for_key, write_targets};
|
||
use miroir_core::scatter::{
|
||
MockNodeClient, SearchRequest,
|
||
dfs_query_then_fetch_search, plan_search_scatter,
|
||
};
|
||
use miroir_core::topology::{Node, NodeId, NodeStatus, Topology};
|
||
use serde_json::{json, Value};
|
||
use std::collections::HashMap;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helper: build a 3-node, 1 replica group topology
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn three_node_topology(shards: u32) -> Topology {
|
||
let mut topo = Topology::new(shards, 1, 3);
|
||
for i in 0..3u32 {
|
||
let node = Node::new(
|
||
NodeId::new(format!("node-{i}")),
|
||
format!("http://localhost:810{i}"),
|
||
0,
|
||
);
|
||
topo.add_node(node);
|
||
topo.node_mut(&NodeId::new(format!("node-{i}")))
|
||
.unwrap()
|
||
.transition_to(NodeStatus::Active)
|
||
.unwrap();
|
||
}
|
||
topo
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helper: build a 2-group, 2-node-per-group topology (4 nodes)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn two_group_topology(shards: u32) -> Topology {
|
||
let mut topo = Topology::new(shards, 2, 2);
|
||
for i in 0..2u32 {
|
||
let node = Node::new(
|
||
NodeId::new(format!("node-{i}")),
|
||
format!("http://localhost:810{i}"),
|
||
0,
|
||
);
|
||
topo.add_node(node);
|
||
topo.node_mut(&NodeId::new(format!("node-{i}")))
|
||
.unwrap()
|
||
.transition_to(NodeStatus::Active)
|
||
.unwrap();
|
||
}
|
||
for i in 2..4u32 {
|
||
let node = Node::new(
|
||
NodeId::new(format!("node-{i}")),
|
||
format!("http://localhost:810{i}"),
|
||
1,
|
||
);
|
||
topo.add_node(node);
|
||
topo.node_mut(&NodeId::new(format!("node-{i}")))
|
||
.unwrap()
|
||
.transition_to(NodeStatus::Active)
|
||
.unwrap();
|
||
}
|
||
topo
|
||
}
|
||
|
||
// All MiroirCode variants for iteration
|
||
const ALL_CODES: [MiroirCode; 10] = [
|
||
MiroirCode::PrimaryKeyRequired,
|
||
MiroirCode::NoQuorum,
|
||
MiroirCode::ShardUnavailable,
|
||
MiroirCode::ReservedField,
|
||
MiroirCode::IdempotencyKeyReused,
|
||
MiroirCode::SettingsVersionStale,
|
||
MiroirCode::MultiAliasNotWritable,
|
||
MiroirCode::JwtInvalid,
|
||
MiroirCode::JwtScopeDenied,
|
||
MiroirCode::InvalidAuth,
|
||
];
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// DoD 1: 1000 documents indexed across 3 nodes, each retrievable by ID
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn test_1000_docs_shard_assignment_coverage() {
|
||
let shards = 8u32;
|
||
let topo = three_node_topology(shards);
|
||
|
||
let mut shard_counts: HashMap<u32, Vec<String>> = HashMap::new();
|
||
for i in 0..1000u32 {
|
||
let pk = format!("doc-{i}");
|
||
let shard = shard_for_key(&pk, shards);
|
||
shard_counts.entry(shard).or_default().push(pk);
|
||
}
|
||
|
||
// Every shard should have at least one document
|
||
assert_eq!(shard_counts.len(), shards as usize, "all shards should receive documents");
|
||
|
||
// All write targets should be reachable
|
||
for shard_id in 0..shards {
|
||
let targets = write_targets(shard_id, &topo);
|
||
assert_eq!(targets.len(), 3, "RF=3 means 3 write targets per shard");
|
||
for node_id in &targets {
|
||
assert!(topo.node(node_id).is_some(), "target node {} should exist", node_id);
|
||
}
|
||
}
|
||
|
||
// Total count should be 1000
|
||
let total: usize = shard_counts.values().map(|v| v.len()).sum();
|
||
assert_eq!(total, 1000);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// DoD 2: unique-keyword search finds every doc exactly once
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[tokio::test]
|
||
async fn test_unique_keyword_search_deduplication() {
|
||
let shards = 4u32;
|
||
let topo = two_group_topology(shards);
|
||
|
||
let mut mock = MockNodeClient::default();
|
||
|
||
// Compute covering set for query_seq=0
|
||
let plan = plan_search_scatter(&topo, 0, 2, shards, None).await;
|
||
|
||
// Build per-node responses by accumulating all docs for each node.
|
||
// Multiple shards may map to the same node; a real Meilisearch node
|
||
// returns all its matching docs in a single response.
|
||
let mut node_hits: HashMap<NodeId, Vec<Value>> = HashMap::new();
|
||
let mut node_totals: HashMap<NodeId, u64> = HashMap::new();
|
||
|
||
for (shard_id, node_id) in &plan.shard_to_node {
|
||
let doc1 = json!({
|
||
"id": format!("doc-{}-a", shard_id),
|
||
"title": format!("unique-keyword-{}", shard_id * 2),
|
||
"_miroir_shard": shard_id,
|
||
"_rankingScore": 0.95,
|
||
});
|
||
let doc2 = json!({
|
||
"id": format!("doc-{}-b", shard_id),
|
||
"title": format!("unique-keyword-{}", shard_id * 2 + 1),
|
||
"_miroir_shard": shard_id,
|
||
"_rankingScore": 0.90,
|
||
});
|
||
|
||
let hits = node_hits.entry(node_id.clone()).or_default();
|
||
hits.push(doc1);
|
||
hits.push(doc2);
|
||
*node_totals.entry(node_id.clone()).or_insert(0) += 2;
|
||
|
||
mock.preflight_responses.insert(node_id.clone(), miroir_core::scatter::PreflightResponse {
|
||
total_docs: 100,
|
||
avg_doc_length: 500.0,
|
||
term_stats: HashMap::new(),
|
||
});
|
||
}
|
||
|
||
// Now build one response per node with all its accumulated docs
|
||
for (node_id, hits) in node_hits {
|
||
let total = node_totals.remove(&node_id).unwrap();
|
||
mock.responses.insert(node_id, json!({
|
||
"hits": hits,
|
||
"estimatedTotalHits": total,
|
||
"processingTimeMs": 5,
|
||
}));
|
||
}
|
||
|
||
let strategy = ScoreMergeStrategy::new();
|
||
let req = SearchRequest {
|
||
index_uid: "test-index".to_string(),
|
||
query: Some("unique-keyword".to_string()),
|
||
offset: 0,
|
||
limit: 100,
|
||
filter: None,
|
||
facets: None,
|
||
ranking_score: false,
|
||
body: json!({}),
|
||
global_idf: None,
|
||
};
|
||
|
||
let result = dfs_query_then_fetch_search(
|
||
plan,
|
||
&mock,
|
||
req,
|
||
&topo,
|
||
miroir_core::config::UnavailableShardPolicy::Partial,
|
||
&strategy,
|
||
)
|
||
.await
|
||
.unwrap();
|
||
|
||
// Every document should appear exactly once (no duplicates)
|
||
let mut seen_ids: HashMap<String, usize> = HashMap::new();
|
||
for hit in &result.hits {
|
||
let id = hit["id"].as_str().unwrap().to_string();
|
||
*seen_ids.entry(id).or_insert(0) += 1;
|
||
}
|
||
|
||
for (id, count) in &seen_ids {
|
||
assert_eq!(*count, 1, "doc {} appeared {} times, expected 1", id, count);
|
||
}
|
||
|
||
// Should find all 8 docs (2 per shard × 4 shards)
|
||
assert_eq!(result.hits.len(), 8, "expected 8 unique hits across 4 shards");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// DoD 3: facet aggregation across 3 color values sums correctly
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn test_facet_aggregation_sums_correctly() {
|
||
// Test the facet merge logic directly using the same algorithm the merger uses.
|
||
// Since merge_facets is private, we replicate the merge logic here to validate
|
||
// the aggregation contract.
|
||
use std::collections::BTreeMap;
|
||
|
||
// Shard 0: red=10, green=5, blue=3
|
||
let mut shard0: BTreeMap<String, BTreeMap<String, u64>> = BTreeMap::new();
|
||
let mut colors0: BTreeMap<String, u64> = BTreeMap::new();
|
||
colors0.insert("red".to_string(), 10);
|
||
colors0.insert("green".to_string(), 5);
|
||
colors0.insert("blue".to_string(), 3);
|
||
shard0.insert("color".to_string(), colors0);
|
||
|
||
// Shard 1: red=7, green=12, blue=8
|
||
let mut shard1: BTreeMap<String, BTreeMap<String, u64>> = BTreeMap::new();
|
||
let mut colors1: BTreeMap<String, u64> = BTreeMap::new();
|
||
colors1.insert("red".to_string(), 7);
|
||
colors1.insert("green".to_string(), 12);
|
||
colors1.insert("blue".to_string(), 8);
|
||
shard1.insert("color".to_string(), colors1);
|
||
|
||
// Merge: sum per-value facet counts across shards
|
||
let mut merged: BTreeMap<String, BTreeMap<String, u64>> = BTreeMap::new();
|
||
for shard_facets in &[&shard0, &shard1] {
|
||
for (facet_name, values) in *shard_facets {
|
||
let entry = merged.entry(facet_name.clone()).or_default();
|
||
for (value, count) in values {
|
||
*entry.entry(value.clone()).or_insert(0) += count;
|
||
}
|
||
}
|
||
}
|
||
|
||
let colors = merged.get("color").unwrap();
|
||
assert_eq!(*colors.get("red").unwrap(), 17, "red: 10+7=17");
|
||
assert_eq!(*colors.get("green").unwrap(), 17, "green: 5+12=17");
|
||
assert_eq!(*colors.get("blue").unwrap(), 11, "blue: 3+8=11");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// DoD 4: offset/limit paging preserves global ordering
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[tokio::test]
|
||
async fn test_paging_preserves_global_ordering() {
|
||
let shards = 3u32;
|
||
let topo = three_node_topology(shards);
|
||
|
||
// Each shard returns 5 hits with descending scores
|
||
let mut mock = MockNodeClient::default();
|
||
|
||
// Build covering set for page 1
|
||
let plan1 = plan_search_scatter(&topo, 0, 3, shards, None).await;
|
||
|
||
for (shard_id, node_id) in &plan1.shard_to_node {
|
||
let mut hits = Vec::new();
|
||
for i in 0..5u32 {
|
||
hits.push(json!({
|
||
"id": format!("s{}-d{}", shard_id, i),
|
||
"_miroir_shard": shard_id,
|
||
"_rankingScore": 1.0 - (i as f64 * 0.1) - (*shard_id as f64 * 0.01),
|
||
}));
|
||
}
|
||
let response = json!({
|
||
"hits": hits,
|
||
"estimatedTotalHits": 5,
|
||
"processingTimeMs": 2,
|
||
});
|
||
mock.responses.insert(node_id.clone(), response);
|
||
mock.preflight_responses.insert(node_id.clone(), miroir_core::scatter::PreflightResponse {
|
||
total_docs: 50,
|
||
avg_doc_length: 500.0,
|
||
term_stats: HashMap::new(),
|
||
});
|
||
}
|
||
|
||
let strategy = ScoreMergeStrategy::new();
|
||
|
||
// Page 1: offset=0, limit=5
|
||
let req1 = SearchRequest {
|
||
index_uid: "test".to_string(),
|
||
query: Some("test".to_string()),
|
||
offset: 0,
|
||
limit: 5,
|
||
filter: None,
|
||
facets: None,
|
||
ranking_score: true,
|
||
body: json!({}),
|
||
global_idf: None,
|
||
};
|
||
let result1 = dfs_query_then_fetch_search(
|
||
plan1, &mock, req1, &topo,
|
||
miroir_core::config::UnavailableShardPolicy::Partial, &strategy,
|
||
).await.unwrap();
|
||
|
||
// Page 2: offset=5, limit=5 (different query_seq to get different covering set)
|
||
let plan2 = plan_search_scatter(&topo, 1, 3, shards, None).await;
|
||
// Re-use same mock responses since the node set is the same for this topology
|
||
let req2 = SearchRequest {
|
||
index_uid: "test".to_string(),
|
||
query: Some("test".to_string()),
|
||
offset: 5,
|
||
limit: 5,
|
||
filter: None,
|
||
facets: None,
|
||
ranking_score: true,
|
||
body: json!({}),
|
||
global_idf: None,
|
||
};
|
||
let result2 = dfs_query_then_fetch_search(
|
||
plan2, &mock, req2, &topo,
|
||
miroir_core::config::UnavailableShardPolicy::Partial, &strategy,
|
||
).await.unwrap();
|
||
|
||
// Pages should not overlap
|
||
let page1_ids: std::collections::HashSet<String> = result1.hits.iter()
|
||
.filter_map(|h| h["id"].as_str().map(|s| s.to_string()))
|
||
.collect();
|
||
let page2_ids: std::collections::HashSet<String> = result2.hits.iter()
|
||
.filter_map(|h| h["id"].as_str().map(|s| s.to_string()))
|
||
.collect();
|
||
|
||
let overlap: std::collections::HashSet<_> = page1_ids.intersection(&page2_ids).collect();
|
||
assert!(overlap.is_empty(), "pages should not overlap, but found: {:?}", overlap);
|
||
|
||
// Combined should have 10 hits total (5 per page)
|
||
assert_eq!(result1.hits.len(), 5, "page 1 should have 5 hits");
|
||
assert_eq!(result2.hits.len(), 5, "page 2 should have 5 hits");
|
||
|
||
// Verify global ordering: page 1 scores >= page 2 scores
|
||
let page1_max_score = result1.hits.last()
|
||
.and_then(|h| h["_rankingScore"].as_f64())
|
||
.unwrap_or(0.0);
|
||
let page2_min_score = result2.hits.first()
|
||
.and_then(|h| h["_rankingScore"].as_f64())
|
||
.unwrap_or(1.0);
|
||
assert!(
|
||
page1_max_score >= page2_min_score,
|
||
"page 1 min score ({}) should be >= page 2 max score ({})",
|
||
page1_max_score, page2_min_score
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// DoD 5: write with one group completely down still succeeds + X-Miroir-Degraded
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn test_degraded_write_one_group_down() {
|
||
let shards = 4u32;
|
||
let mut topo = two_group_topology(shards);
|
||
|
||
// Take down all nodes in group 1
|
||
for i in 2..4u32 {
|
||
topo.node_mut(&NodeId::new(format!("node-{i}")))
|
||
.unwrap()
|
||
.transition_to(NodeStatus::Failed)
|
||
.unwrap();
|
||
}
|
||
|
||
// Verify group 1 nodes are not healthy
|
||
let node_map = topo.node_map();
|
||
for group in topo.groups() {
|
||
if group.id == 1 {
|
||
let healthy = group.healthy_nodes(&node_map);
|
||
assert!(healthy.is_empty(), "group 1 should have no healthy nodes");
|
||
}
|
||
}
|
||
|
||
// For each shard, verify at least one group can still accept writes
|
||
for shard_id in 0..shards {
|
||
let targets = write_targets(shard_id, &topo);
|
||
let group0_targets: Vec<_> = targets.iter()
|
||
.filter(|node_id| {
|
||
topo.node(node_id).map(|n| n.replica_group == 0).unwrap_or(false)
|
||
})
|
||
.collect();
|
||
assert!(!group0_targets.is_empty(), "shard {} should have group 0 targets", shard_id);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_quorum_logic_group_down() {
|
||
// Simulate: 2 groups, RF=2. Group 1 is completely down.
|
||
// Quorum per group = floor(2/2) + 1 = 2.
|
||
// Group 0: 2 of 2 nodes succeed → quorum met.
|
||
// Group 1: 0 of 2 nodes succeed → quorum not met.
|
||
// Overall: at least one group met quorum → write succeeds, degraded header.
|
||
|
||
let rf = 2usize;
|
||
let replica_group_count = 2u32;
|
||
let quorum_per_group = (rf / 2) + 1; // = 2
|
||
|
||
// Group 0: 2 ACKs
|
||
let group0_acks = 2usize;
|
||
assert!(group0_acks >= quorum_per_group, "group 0 met quorum");
|
||
|
||
// Group 1: 0 ACKs
|
||
let group1_acks = 0usize;
|
||
assert!(group1_acks < quorum_per_group, "group 1 missed quorum");
|
||
|
||
// At least one group met quorum → write should succeed
|
||
let quorum_groups = (group0_acks >= quorum_per_group) as usize
|
||
+ (group1_acks >= quorum_per_group) as usize;
|
||
assert!(quorum_groups >= 1, "at least one group met quorum");
|
||
|
||
// Degraded header should be set because group 1 missed quorum
|
||
let degraded_groups = replica_group_count - quorum_groups as u32;
|
||
assert_eq!(degraded_groups, 1, "one group is degraded");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// DoD 6: error-format parity with Meilisearch
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn test_error_shape_byte_for_byte_parity() {
|
||
// Every MiroirCode must produce a JSON object with exactly the keys:
|
||
// {message, code, type, link} where code starts with "miroir_".
|
||
for code in ALL_CODES {
|
||
let err = MeilisearchError::new(code, format!("test message for {:?}", code));
|
||
let json_str = serde_json::to_string(&err).unwrap();
|
||
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
|
||
|
||
// Must have all four fields
|
||
assert!(parsed.get("message").is_some(), "{:?}: missing message", code);
|
||
assert!(parsed.get("code").is_some(), "{:?}: missing code", code);
|
||
assert!(parsed.get("type").is_some(), "{:?}: missing type", code);
|
||
assert!(parsed.get("link").is_some(), "{:?}: missing link", code);
|
||
|
||
// Code must start with miroir_
|
||
let code_str = parsed["code"].as_str().unwrap();
|
||
assert!(
|
||
code_str.starts_with("miroir_"),
|
||
"{:?}: code '{}' should start with miroir_",
|
||
code, code_str
|
||
);
|
||
|
||
// Type must be a valid Meilisearch error type
|
||
let type_str = parsed["type"].as_str().unwrap();
|
||
assert!(
|
||
["invalid_request", "auth", "internal", "system"].contains(&type_str),
|
||
"{:?}: type '{}' is not a valid Meilisearch error type",
|
||
code, type_str
|
||
);
|
||
|
||
// Link must be a string pointing to docs
|
||
let link_str = parsed["link"].as_str().unwrap();
|
||
assert!(
|
||
link_str.contains("docs/errors.md"),
|
||
"{:?}: link '{}' should point to error docs",
|
||
code, link_str
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_forwarded_meilisearch_error_preserves_shape() {
|
||
let meili_body = r#"{
|
||
"message": "Index `movies` not found.",
|
||
"code": "index_not_found",
|
||
"type": "invalid_request",
|
||
"link": "https://docs.meilisearch.com/errors#index_not_found"
|
||
}"#;
|
||
|
||
let err = MeilisearchError::forwarded(meili_body).unwrap();
|
||
let roundtrip = serde_json::to_string(&err).unwrap();
|
||
let original: serde_json::Value = serde_json::from_str(meili_body).unwrap();
|
||
let result: serde_json::Value = serde_json::from_str(&roundtrip).unwrap();
|
||
|
||
assert_eq!(original["message"], result["message"]);
|
||
assert_eq!(original["code"], result["code"]);
|
||
assert_eq!(original["type"], result["type"]);
|
||
assert_eq!(original["link"], result["link"]);
|
||
}
|
||
|
||
#[test]
|
||
fn test_forwarded_document_not_found_error() {
|
||
let meili_body = r#"{
|
||
"message": "Document `abc123` not found.",
|
||
"code": "document_not_found",
|
||
"type": "invalid_request",
|
||
"link": "https://docs.meilisearch.com/errors#document_not_found"
|
||
}"#;
|
||
|
||
let err = MeilisearchError::forwarded(meili_body).unwrap();
|
||
assert_eq!(err.code, "document_not_found");
|
||
assert_eq!(err.error_type, ErrorType::InvalidRequest);
|
||
}
|
||
|
||
#[test]
|
||
fn test_forwarded_invalid_api_key_error() {
|
||
let meili_body = r#"{
|
||
"message": "The provided API key is invalid.",
|
||
"code": "invalid_api_key",
|
||
"type": "auth",
|
||
"link": "https://docs.meilisearch.com/errors#invalid_api_key"
|
||
}"#;
|
||
|
||
let err = MeilisearchError::forwarded(meili_body).unwrap();
|
||
assert_eq!(err.code, "invalid_api_key");
|
||
assert_eq!(err.error_type, ErrorType::Auth);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// DoD 7: GET /_miroir/topology matches plan §10 shape
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn test_topology_response_shape() {
|
||
use miroir_proxy::routes::admin_endpoints::{NodeInfo, TopologyResponse};
|
||
|
||
let response = TopologyResponse {
|
||
shards: 64,
|
||
replication_factor: 2,
|
||
nodes: vec![
|
||
NodeInfo {
|
||
id: "node-0".to_string(),
|
||
address: "http://meili-0.search.svc:7700".to_string(),
|
||
status: "active".to_string(),
|
||
shard_count: 32,
|
||
last_seen_ms: 100,
|
||
error: None,
|
||
},
|
||
NodeInfo {
|
||
id: "node-1".to_string(),
|
||
address: "http://meili-1.search.svc:7700".to_string(),
|
||
status: "degraded".to_string(),
|
||
shard_count: 32,
|
||
last_seen_ms: 5000,
|
||
error: Some("connection refused".to_string()),
|
||
},
|
||
],
|
||
degraded_node_count: 1,
|
||
rebalance_in_progress: false,
|
||
fully_covered: false,
|
||
};
|
||
|
||
let json_str = serde_json::to_string(&response).unwrap();
|
||
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
|
||
|
||
// Plan §10 required fields
|
||
assert!(parsed.get("shards").is_some(), "missing shards");
|
||
assert!(parsed.get("replication_factor").is_some(), "missing replication_factor");
|
||
assert!(parsed.get("nodes").is_some(), "missing nodes");
|
||
assert!(parsed.get("degraded_node_count").is_some(), "missing degraded_node_count");
|
||
assert!(parsed.get("rebalance_in_progress").is_some(), "missing rebalance_in_progress");
|
||
assert!(parsed.get("fully_covered").is_some(), "missing fully_covered");
|
||
|
||
// Validate types
|
||
assert!(parsed["shards"].is_number());
|
||
assert!(parsed["replication_factor"].is_number());
|
||
assert!(parsed["nodes"].is_array());
|
||
assert!(parsed["degraded_node_count"].is_number());
|
||
assert!(parsed["rebalance_in_progress"].is_boolean());
|
||
assert!(parsed["fully_covered"].is_boolean());
|
||
|
||
// Validate node shape
|
||
let nodes = parsed["nodes"].as_array().unwrap();
|
||
assert_eq!(nodes.len(), 2);
|
||
for node in nodes {
|
||
assert!(node.get("id").is_some(), "node missing id");
|
||
assert!(node.get("status").is_some(), "node missing status");
|
||
assert!(node.get("shard_count").is_some(), "node missing shard_count");
|
||
assert!(node.get("last_seen_ms").is_some(), "node missing last_seen_ms");
|
||
}
|
||
|
||
// Second node should have error field
|
||
assert!(nodes[1].get("error").is_some());
|
||
// First node should not have error field (skip_serializing_if = None)
|
||
assert!(nodes[0].get("error").is_none());
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Additional: search response field stripping
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn test_search_response_strips_internal_fields() {
|
||
use miroir_proxy::routes::search::strip_internal_fields;
|
||
|
||
// Case 1: _miroir_shard and _rankingScore both present, client didn't request score
|
||
let mut hit = json!({
|
||
"id": "doc-1",
|
||
"title": "Test Document",
|
||
"_miroir_shard": 3,
|
||
"_rankingScore": 0.95,
|
||
});
|
||
strip_internal_fields(&mut hit, false);
|
||
assert!(hit.get("_miroir_shard").is_none(), "_miroir_shard should be stripped");
|
||
assert!(hit.get("_rankingScore").is_none(), "_rankingScore should be stripped when not requested");
|
||
|
||
// Case 2: client requested ranking score
|
||
let mut hit2 = json!({
|
||
"id": "doc-2",
|
||
"title": "Another Document",
|
||
"_miroir_shard": 5,
|
||
"_rankingScore": 0.88,
|
||
});
|
||
strip_internal_fields(&mut hit2, true);
|
||
assert!(hit2.get("_miroir_shard").is_none(), "_miroir_shard should always be stripped");
|
||
assert!(hit2.get("_rankingScore").is_some(), "_rankingScore should be kept when requested");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Additional: reserved field contract
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn test_reserved_field_rejection() {
|
||
let err = MeilisearchError::new(
|
||
MiroirCode::ReservedField,
|
||
"document contains reserved field `_miroir_shard`",
|
||
);
|
||
let json: serde_json::Value = serde_json::to_value(&err).unwrap();
|
||
|
||
assert_eq!(json["code"], "miroir_reserved_field");
|
||
assert_eq!(json["type"], "invalid_request");
|
||
assert_eq!(err.http_status(), 400);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Additional: auth error shape matches Meilisearch
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn test_auth_error_shapes_match_meilisearch() {
|
||
let err = MeilisearchError::new(MiroirCode::InvalidAuth, "The provided authorization is invalid.");
|
||
assert_eq!(err.http_status(), 401);
|
||
let json: serde_json::Value = serde_json::to_value(&err).unwrap();
|
||
assert_eq!(json["type"], "auth");
|
||
|
||
let err = MeilisearchError::new(MiroirCode::JwtInvalid, "JWT signature verification failed");
|
||
assert_eq!(err.http_status(), 401);
|
||
|
||
let err = MeilisearchError::new(MiroirCode::JwtScopeDenied, "insufficient scope");
|
||
assert_eq!(err.http_status(), 403);
|
||
}
|