- Add check_docker_available() to integration.rs and docker_compose_integration.rs - Add skip_if_no_miroir! macro for graceful test skipping - Fix helm_schema_rejects_local_backend_with_replicas_gt_1 test path - Fix uninlined format args for clippy compliance - Fix unused variable warning in p10_2_node_master_key_rotation.rs - Add #[allow] attributes for unused code in p10_5_scoped_key_rotation.rs Resolves: bf-1lyu5 (integration tests skip gracefully) Resolves: bf-e0595 (Phase 10 acceptance tests - p10_7 fix) All 1777 tests pass when Docker is unavailable. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
654 lines
20 KiB
Rust
654 lines
20 KiB
Rust
// Miroir Integration Tests
|
|
//
|
|
// Tests the full Miroir stack with 3 Meilisearch nodes via docker-compose.
|
|
// Per plan §8: Integration tests validate end-to-end behavior including
|
|
// document distribution, shard coverage, facet aggregation, paging, settings
|
|
// broadcast, task polling, and node failure with RF=2.
|
|
//
|
|
// Prerequisites:
|
|
// - docker-compose-dev stack running (Miroir on port 7700, nodes on 7701-7703)
|
|
// - For node_failure_rf2 test: docker-compose-dev-rf2 stack (RF=2, 6 nodes)
|
|
//
|
|
// Run:
|
|
// cargo test --test integration -- --test-threads=1
|
|
//
|
|
// Environment variables:
|
|
// - `MIROIR_TEST_SKIP_DOCKER`: If set, skip these tests (when Docker unavailable)
|
|
|
|
use meilisearch_sdk::{client::Client, indexes::Index, search::SearchResults, tasks::Task};
|
|
use serde_json::json;
|
|
use serde_json::Value;
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::env;
|
|
use std::time::Duration;
|
|
use tokio::time::sleep;
|
|
|
|
const MIROIR_PORT: u16 = 7700;
|
|
const NODE_PORTS: [u16; 3] = [7701, 7702, 7703];
|
|
const MASTER_KEY: &str = "dev-key";
|
|
const NODE_KEY: &str = "dev-node-key";
|
|
|
|
/// Check if Miroir integration tests should skip.
|
|
///
|
|
/// Returns Ok(()) if Miroir is available, Err(skip_reason) if not.
|
|
fn check_miroir_available() -> Result<(), String> {
|
|
// Check explicit skip flag
|
|
if env::var("MIROIR_TEST_SKIP_DOCKER").is_ok() {
|
|
return Err(
|
|
"Miroir integration tests skipped via MIROIR_TEST_SKIP_DOCKER. \
|
|
Unset MIROIR_TEST_SKIP_DOCKER and ensure docker-compose stack is running."
|
|
.to_string(),
|
|
);
|
|
}
|
|
|
|
// Try to connect to Miroir port using a simple TCP check
|
|
use std::net::ToSocketAddrs;
|
|
use std::time::Duration;
|
|
|
|
let addr = format!("localhost:{MIROIR_PORT}");
|
|
let addrs: Vec<std::net::SocketAddr> = addr
|
|
.to_socket_addrs()
|
|
.map_err(|e| format!("Failed to resolve address {addr}: {e}"))?
|
|
.collect();
|
|
|
|
if addrs.is_empty() {
|
|
return Err(format!(
|
|
"No addresses found for {addr}. \
|
|
Ensure docker-compose stack is running: docker compose -f examples/docker-compose-dev.yml up -d. \
|
|
Or set MIROIR_TEST_SKIP_DOCKER=1 to skip."
|
|
));
|
|
}
|
|
|
|
// Try each resolved address
|
|
for socket_addr in addrs {
|
|
if std::net::TcpStream::connect_timeout(&socket_addr, Duration::from_secs(2)).is_ok() {
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
Err(format!(
|
|
"Failed to connect to Miroir at http://localhost:{MIROIR_PORT}. \
|
|
Ensure docker-compose stack is running: docker compose -f examples/docker-compose-dev.yml up -d. \
|
|
Or set MIROIR_TEST_SKIP_DOCKER=1 to skip."
|
|
))
|
|
}
|
|
|
|
/// Macro to skip test if Miroir is unavailable
|
|
macro_rules! skip_if_no_miroir {
|
|
() => {
|
|
match check_miroir_available() {
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
eprintln!("Skipping test: {e}");
|
|
return Ok(());
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
/// Test document
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
struct TestDoc {
|
|
id: String,
|
|
title: String,
|
|
content: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
color: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
score: Option<i32>,
|
|
}
|
|
|
|
/// Helper: Get Miroir client
|
|
fn miroir_client() -> Client {
|
|
let url = format!("http://localhost:{MIROIR_PORT}");
|
|
Client::new(url, Some(MASTER_KEY.to_string())).expect("Failed to create Miroir client")
|
|
}
|
|
|
|
/// Helper: Get direct client to a Meilisearch node
|
|
fn node_client(port: u16) -> Client {
|
|
let url = format!("http://localhost:{port}");
|
|
Client::new(url, Some(NODE_KEY.to_string())).expect("Failed to create Meilisearch node client")
|
|
}
|
|
|
|
/// Helper: Wait for a task to complete
|
|
async fn wait_for_task(
|
|
client: &Client,
|
|
task_info: meilisearch_sdk::task_info::TaskInfo,
|
|
) -> Result<Task, Box<dyn std::error::Error>> {
|
|
let timeout = Duration::from_secs(30);
|
|
let start = std::time::Instant::now();
|
|
let task_uid = task_info.task_uid;
|
|
|
|
loop {
|
|
let task = client.get_task(&task_info).await?;
|
|
// Check if task is finished (Succeeded or Failed)
|
|
match task {
|
|
Task::Succeeded { .. } => return Ok(task),
|
|
Task::Failed { .. } => return Err(format!("Task {task_uid} failed: {task:?}").into()),
|
|
_ => {}
|
|
}
|
|
|
|
if start.elapsed() > timeout {
|
|
return Err(format!("Task {task_uid} timed out").into());
|
|
}
|
|
|
|
sleep(Duration::from_millis(200)).await;
|
|
}
|
|
}
|
|
|
|
/// Helper: Create or get index with primary key
|
|
async fn get_index(client: &Client, name: &str) -> Result<Index, Box<dyn std::error::Error>> {
|
|
match client.get_index(name).await {
|
|
Ok(_) => Ok(client.index(name)),
|
|
Err(_) => {
|
|
let task_info = client.create_index(name, Some("id")).await?;
|
|
wait_for_task(client, task_info).await?;
|
|
Ok(client.index(name))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Helper: Delete index if exists
|
|
async fn delete_index(client: &Client, name: &str) -> Result<(), Box<dyn std::error::Error>> {
|
|
if client.get_index(name).await.is_ok() {
|
|
let task_info = client.delete_index(name).await?;
|
|
let _ = wait_for_task(client, task_info).await;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Helper: Ensure Miroir is healthy
|
|
async fn ensure_healthy() -> Result<(), Box<dyn std::error::Error>> {
|
|
let client = reqwest::Client::new();
|
|
let url = format!("http://localhost:{MIROIR_PORT}/health");
|
|
|
|
for _ in 0..30 {
|
|
match client.get(&url).send().await {
|
|
Ok(resp) if resp.status().is_success() => return Ok(()),
|
|
_ => sleep(Duration::from_millis(500)).await,
|
|
}
|
|
}
|
|
|
|
Err("Miroir not healthy after timeout".into())
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 1: Document round-trip (plan §8)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn document_round_trip() -> Result<(), Box<dyn std::error::Error>> {
|
|
skip_if_no_miroir!();
|
|
ensure_healthy().await?;
|
|
let client = miroir_client();
|
|
let index_name = "test_round_trip";
|
|
|
|
// Clean up
|
|
delete_index(&client, index_name).await?;
|
|
let index = get_index(&client, index_name).await?;
|
|
|
|
// Index 1000 documents
|
|
let mut docs = Vec::new();
|
|
for i in 0..1000 {
|
|
docs.push(json!({
|
|
"id": format!("doc-{:05}", i),
|
|
"title": format!("Document {}", i),
|
|
"content": format!("Content for document {}", i),
|
|
}));
|
|
}
|
|
|
|
let task = index.add_documents(&docs, None).await?;
|
|
wait_for_task(&client, task).await?;
|
|
|
|
// Verify all documents can be retrieved by ID
|
|
for i in 0..1000 {
|
|
let id = format!("doc-{i:05}");
|
|
let doc: TestDoc = index.get_document(&id).await?;
|
|
assert_eq!(doc.id, id);
|
|
assert_eq!(doc.title, format!("Document {i}"));
|
|
}
|
|
|
|
// Verify documents are distributed across all 3 nodes
|
|
let mut node_doc_counts = HashMap::new();
|
|
for &port in &NODE_PORTS {
|
|
let node = node_client(port);
|
|
if let Ok(idx) = node.get_index(index_name).await {
|
|
let stats = idx.get_stats().await?;
|
|
let count = stats.number_of_documents;
|
|
node_doc_counts.insert(port, count);
|
|
}
|
|
}
|
|
|
|
// At least 2 nodes should have documents (distribution)
|
|
let populated_nodes = node_doc_counts.values().filter(|&&c| c > 0).count();
|
|
assert!(
|
|
populated_nodes >= 2,
|
|
"Documents not distributed: {node_doc_counts:?}"
|
|
);
|
|
|
|
// Total across nodes equals 1000
|
|
let total: usize = node_doc_counts.values().sum();
|
|
assert_eq!(total, 1000, "Total documents mismatch: {node_doc_counts:?}");
|
|
|
|
// Clean up
|
|
delete_index(&client, index_name).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 2: Search covers all shards (plan §8)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn search_covers_all_shards() -> Result<(), Box<dyn std::error::Error>> {
|
|
skip_if_no_miroir!();
|
|
ensure_healthy().await?;
|
|
let client = miroir_client();
|
|
let index_name = "test_shard_coverage";
|
|
|
|
delete_index(&client, index_name).await?;
|
|
let index = get_index(&client, index_name).await?;
|
|
|
|
// Index documents with unique keywords (one per document)
|
|
let mut docs = Vec::new();
|
|
for i in 0..100 {
|
|
docs.push(json!({
|
|
"id": format!("shard-doc-{:03}", i),
|
|
"title": format!("unique_keyword_{}", i),
|
|
"content": "content",
|
|
}));
|
|
}
|
|
|
|
let task = index.add_documents(&docs, None).await?;
|
|
wait_for_task(&client, task).await?;
|
|
|
|
// Search for each unique keyword — every search must return exactly 1 hit
|
|
for i in 0..100 {
|
|
let keyword = format!("unique_keyword_{i}");
|
|
let results: SearchResults<Value> = index.search().with_query(&keyword).execute().await?;
|
|
let hits = results.hits;
|
|
assert_eq!(
|
|
hits.len(),
|
|
1,
|
|
"Search for '{}' returned {} hits",
|
|
keyword,
|
|
hits.len()
|
|
);
|
|
}
|
|
|
|
delete_index(&client, index_name).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 3: Facet aggregation (plan §8)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn facet_aggregation() -> Result<(), Box<dyn std::error::Error>> {
|
|
skip_if_no_miroir!();
|
|
ensure_healthy().await?;
|
|
let client = miroir_client();
|
|
let index_name = "test_facets";
|
|
|
|
delete_index(&client, index_name).await?;
|
|
let index = get_index(&client, index_name).await?;
|
|
|
|
// Set up filterable attributes for color
|
|
let task = index.set_filterable_attributes(["color"]).await?;
|
|
wait_for_task(&client, task).await?;
|
|
|
|
// Index 100 documents across 3 color values
|
|
let colors = ["red", "green", "blue"];
|
|
let mut docs = Vec::new();
|
|
for i in 0..100 {
|
|
let color = colors[i % 3];
|
|
docs.push(json!({
|
|
"id": format!("facet-doc-{:03}", i),
|
|
"title": "Product",
|
|
"color": color,
|
|
}));
|
|
}
|
|
|
|
let task = index.add_documents(&docs, None).await?;
|
|
wait_for_task(&client, task).await?;
|
|
|
|
// Facet counts must sum to 100
|
|
use meilisearch_sdk::search::Selectors;
|
|
let facets = ["color"];
|
|
let results: SearchResults<Value> = index
|
|
.search()
|
|
.with_facets(Selectors::Some(&facets[..]))
|
|
.execute()
|
|
.await?;
|
|
let facet_dist = results
|
|
.facet_distribution
|
|
.as_ref()
|
|
.and_then(|f| f.get("color"))
|
|
.unwrap();
|
|
|
|
let red_count = *facet_dist.get("red").unwrap_or(&0);
|
|
let green_count = *facet_dist.get("green").unwrap_or(&0);
|
|
let blue_count = *facet_dist.get("blue").unwrap_or(&0);
|
|
|
|
let total = red_count + green_count + blue_count;
|
|
assert_eq!(total, 100, "Facet counts sum to {total}, expected 100");
|
|
|
|
// Each color should have at least some documents
|
|
assert!(red_count > 0, "No red documents");
|
|
assert!(green_count > 0, "No green documents");
|
|
assert!(blue_count > 0, "No blue documents");
|
|
|
|
delete_index(&client, index_name).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 4: Offset/limit paging (plan §8)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn offset_limit_paging() -> Result<(), Box<dyn std::error::Error>> {
|
|
skip_if_no_miroir!();
|
|
ensure_healthy().await?;
|
|
let client = miroir_client();
|
|
let index_name = "test_paging";
|
|
|
|
delete_index(&client, index_name).await?;
|
|
let index = get_index(&client, index_name).await?;
|
|
|
|
// Index 50 documents with known scores (use title to control relevance)
|
|
let mut docs = Vec::new();
|
|
for i in 0..50 {
|
|
docs.push(json!({
|
|
"id": format!("page-doc-{:02}", i),
|
|
"title": format!("item {}", 49 - i), // Reverse order for predictable ranking
|
|
"score": i,
|
|
}));
|
|
}
|
|
|
|
let task = index.add_documents(&docs, None).await?;
|
|
wait_for_task(&client, task).await?;
|
|
|
|
// Get single query with limit=50
|
|
let single_page: SearchResults<Value> = index.search().with_limit(50).execute().await?;
|
|
let single_ids: HashSet<String> = single_page
|
|
.hits
|
|
.iter()
|
|
.filter_map(|v| {
|
|
v.result
|
|
.get("id")
|
|
.and_then(|id| id.as_str().map(|s| s.to_string()))
|
|
})
|
|
.collect();
|
|
|
|
// Get 5 pages of 10
|
|
let mut paged_ids = HashSet::new();
|
|
for page in 0..5 {
|
|
let results: SearchResults<Value> = index
|
|
.search()
|
|
.with_limit(10)
|
|
.with_offset(page * 10)
|
|
.execute()
|
|
.await?;
|
|
for hit in results.hits {
|
|
let id = hit.result.get("id").and_then(|id| id.as_str()).unwrap();
|
|
paged_ids.insert(id.to_string());
|
|
}
|
|
}
|
|
|
|
// Same total count
|
|
assert_eq!(single_ids.len(), 50);
|
|
assert_eq!(paged_ids.len(), 50);
|
|
|
|
// No duplicates in paged results
|
|
assert_eq!(paged_ids.len(), 50, "Duplicates found in paged results");
|
|
|
|
// Paged and single query return the same documents
|
|
assert_eq!(
|
|
single_ids, paged_ids,
|
|
"Paged results differ from single query"
|
|
);
|
|
|
|
// Order is preserved (concatenated pages match single page order)
|
|
let single_order: Vec<String> = single_page
|
|
.hits
|
|
.iter()
|
|
.filter_map(|v| {
|
|
v.result
|
|
.get("id")
|
|
.and_then(|id| id.as_str().map(|s| s.to_string()))
|
|
})
|
|
.collect();
|
|
|
|
let mut paged_order = Vec::new();
|
|
for page in 0..5 {
|
|
let results: SearchResults<Value> = index
|
|
.search()
|
|
.with_limit(10)
|
|
.with_offset(page * 10)
|
|
.execute()
|
|
.await?;
|
|
for hit in results.hits {
|
|
let id = hit.result.get("id").and_then(|id| id.as_str()).unwrap();
|
|
paged_order.push(id.to_string());
|
|
}
|
|
}
|
|
|
|
assert_eq!(
|
|
single_order, paged_order,
|
|
"Order differs between paged and single query"
|
|
);
|
|
|
|
delete_index(&client, index_name).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 5: Settings broadcast (plan §8)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn settings_broadcast() -> Result<(), Box<dyn std::error::Error>> {
|
|
skip_if_no_miroir!();
|
|
ensure_healthy().await?;
|
|
let client = miroir_client();
|
|
let index_name = "test_settings";
|
|
|
|
delete_index(&client, index_name).await?;
|
|
let index = get_index(&client, index_name).await?;
|
|
|
|
// Index some documents
|
|
let docs = vec![
|
|
json!({"id": "1", "title": "wireless headphones"}),
|
|
json!({"id": "2", "title": "bluetooth earbuds"}),
|
|
];
|
|
|
|
let task = index.add_documents(&docs, None).await?;
|
|
wait_for_task(&client, task).await?;
|
|
|
|
// Add synonyms via Miroir
|
|
let mut synonyms = HashMap::new();
|
|
synonyms.insert("earbuds".to_string(), vec!["headphones".to_string()]);
|
|
synonyms.insert("wireless".to_string(), vec!["bluetooth".to_string()]);
|
|
|
|
let task_info = index.set_synonyms(&synonyms).await?;
|
|
wait_for_task(&client, task_info).await?;
|
|
|
|
// Verify all 3 nodes have the synonyms
|
|
for &port in &NODE_PORTS {
|
|
let node = node_client(port);
|
|
if let Ok(idx) = node.get_index(index_name).await {
|
|
let settings = idx.get_settings().await?;
|
|
let node_synonyms = settings.synonyms.unwrap_or_default();
|
|
assert_eq!(
|
|
node_synonyms.get("earbuds"),
|
|
Some(&vec!["headphones".to_string()]),
|
|
"Node port {port} missing synonyms"
|
|
);
|
|
}
|
|
}
|
|
|
|
// Search via synonym returns results
|
|
let results: SearchResults<Value> = index
|
|
.search()
|
|
.with_query("bluetooth headphones")
|
|
.execute()
|
|
.await?;
|
|
assert!(
|
|
!results.hits.is_empty(),
|
|
"Synonym search returned no results"
|
|
);
|
|
|
|
delete_index(&client, index_name).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 6: Task polling (plan §8)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn task_polling() -> Result<(), Box<dyn std::error::Error>> {
|
|
skip_if_no_miroir!();
|
|
ensure_healthy().await?;
|
|
let client = miroir_client();
|
|
let index_name = "test_tasks";
|
|
|
|
delete_index(&client, index_name).await?;
|
|
let index = get_index(&client, index_name).await?;
|
|
|
|
// Index a large batch (500 docs)
|
|
let mut docs = Vec::new();
|
|
for i in 0..500 {
|
|
docs.push(json!({
|
|
"id": format!("task-doc-{:04}", i),
|
|
"title": format!("Document {}", i),
|
|
"content": "content for task polling test",
|
|
}));
|
|
}
|
|
|
|
let task_uid = index.add_documents(&docs, None).await?;
|
|
|
|
// Poll GET /tasks/{id} until succeeded
|
|
let task = wait_for_task(&client, task_uid).await?;
|
|
assert!(
|
|
matches!(task, Task::Succeeded { .. }),
|
|
"Task did not succeed: {task:?}"
|
|
);
|
|
|
|
// Verify all documents are searchable
|
|
for i in 0..500 {
|
|
let id = format!("task-doc-{i:04}");
|
|
let doc: TestDoc = index.get_document(&id).await?;
|
|
assert_eq!(doc.id, id);
|
|
}
|
|
|
|
// Search also returns all documents
|
|
let results: SearchResults<Value> = index
|
|
.search()
|
|
.with_query("content")
|
|
.with_limit(500)
|
|
.execute()
|
|
.await?;
|
|
assert_eq!(
|
|
results.hits.len(),
|
|
500,
|
|
"Search returned {} hits, expected 500",
|
|
results.hits.len()
|
|
);
|
|
|
|
delete_index(&client, index_name).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 7: Node failure with RF=2 (plan §8)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
#[ignore] // Requires docker-compose-dev-rf2 stack
|
|
async fn node_failure_rf2() -> Result<(), Box<dyn std::error::Error>> {
|
|
// This test requires the RF=2 stack with 6 nodes
|
|
let rf2_port = env::var("MIROIR_RF2_PORT")
|
|
.ok()
|
|
.and_then(|p| p.parse().ok())
|
|
.unwrap_or(7700);
|
|
|
|
let client_url = format!("http://localhost:{rf2_port}");
|
|
let index_name = "test_rf2_failure";
|
|
let client = Client::new(&client_url, Some(MASTER_KEY.to_string()))
|
|
.expect("Failed to create Meilisearch client");
|
|
|
|
delete_index(&client, index_name).await?;
|
|
let index = get_index(&client, index_name).await?;
|
|
|
|
// Index 500 documents
|
|
let mut docs = Vec::new();
|
|
for i in 0..500 {
|
|
docs.push(json!({
|
|
"id": format!("rf2-doc-{:04}", i),
|
|
"title": format!("Document {}", i),
|
|
"content": "rf2 failure test content",
|
|
}));
|
|
}
|
|
|
|
let task = index.add_documents(&docs, None).await?;
|
|
wait_for_task(&client, task).await?;
|
|
|
|
// Simulate stopping one node (in real test, use docker-compose stop)
|
|
// For now, we'll just verify the search returns all results
|
|
let results: SearchResults<Value> = index
|
|
.search()
|
|
.with_query("content")
|
|
.with_limit(500)
|
|
.execute()
|
|
.await?;
|
|
assert_eq!(
|
|
results.hits.len(),
|
|
500,
|
|
"Search returned {} hits, expected 500",
|
|
results.hits.len()
|
|
);
|
|
|
|
// Check for X-Miroir-Degraded header (should not appear with RF=2 when one node fails)
|
|
let http_client = reqwest::Client::new();
|
|
let search_url = format!("{client_url}/indexes/{index_name}/search");
|
|
let resp = http_client
|
|
.post(&search_url)
|
|
.header("Authorization", format!("Bearer {MASTER_KEY}"))
|
|
.json(&json!({"q": "content", "limit": 500}))
|
|
.send()
|
|
.await?;
|
|
|
|
// With RF=2, surviving replicas cover all shards, so no degraded header
|
|
assert!(
|
|
resp.headers().get("X-Miroir-Degraded").is_none(),
|
|
"X-Miroir-Degraded header should not appear with RF=2 and one node down"
|
|
);
|
|
|
|
delete_index(&client, index_name).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helper: Check index exists on all nodes
|
|
// ============================================================================
|
|
|
|
#[allow(dead_code)]
|
|
async fn index_exists_on_all_nodes(index_name: &str) -> Result<bool, Box<dyn std::error::Error>> {
|
|
for &port in &NODE_PORTS {
|
|
let node = node_client(port);
|
|
if node.get_index(index_name).await.is_err() {
|
|
return Ok(false);
|
|
}
|
|
}
|
|
Ok(true)
|
|
}
|