miroir/crates/miroir-proxy/tests/integration_test.rs
jedarden 4777bb6834 fix(cli): add --version and --help flags to miroir-proxy
Adds clap-based CLI argument parsing so `miroir-proxy --version`
and `miroir-proxy --help` print version/usage and exit instead
of starting the server and hanging.

Also fixes numerous pre-existing clippy warnings in test files:
- digit grouping inconsistencies
- unused functions/variables
- useless_vec (vec! -> array)
- assert!(true) placeholders
- too_many_arguments

Resolves: bf-31ff
2026-05-26 03:02:56 -04:00

554 lines
16 KiB
Rust

//! Phase 2 Integration Tests: Proxy + API Surface
//!
//! Tests the complete HTTP API surface with real Meilisearch nodes.
//! Uses testcontainers for spinning up Meilisearch instances.
use miroir_core::config::{Config, NodeConfig};
use reqwest::Client;
use serde_json::{json, Value};
use std::time::Duration;
use testcontainers::{runners::AsyncRunner, ImageExt};
use testcontainers_modules::meilisearch::Meilisearch;
use tokio::time::sleep;
/// Test configuration helper.
struct TestSetup {
#[allow(dead_code)]
meilisearch_urls: Vec<String>,
proxy_url: String,
master_key: String,
client: Client,
}
impl TestSetup {
async fn new() -> anyhow::Result<Self> {
// Start 3 Meilisearch nodes
let mut meilisearch_urls = Vec::new();
for i in 0..3 {
let meilisearch = Meilisearch::default()
.with_cmd([format!("--master-key=key{i}")])
.start()
.await?;
let port = meilisearch.get_host_port_ipv4(7700).await?;
let url = format!("http://localhost:{port}");
meilisearch_urls.push(url);
}
// Build topology config
let mut nodes = Vec::new();
for (i, url) in meilisearch_urls.iter().enumerate() {
nodes.push(NodeConfig {
id: format!("node-{i}"),
address: url.clone(),
replica_group: (i % 2) as u32, // 2 replica groups
});
}
let _config = Config {
shards: 16,
replication_factor: 2,
replica_groups: 2,
master_key: "test_master_key".to_string(),
admin: miroir_core::config::AdminConfig {
api_key: "test_admin_key".to_string(),
..Default::default()
},
nodes,
server: miroir_core::config::ServerConfig {
bind: "127.0.0.1".to_string(),
port: 17770, // Non-standard port for testing
..Default::default()
},
..Default::default()
};
// Start the proxy in a separate task
let proxy_url = "http://127.0.0.1:17770";
// Note: In a real test, we'd spawn the proxy here
// For now, we'll assume it's already running
Ok(Self {
meilisearch_urls,
proxy_url: proxy_url.to_string(),
master_key: "test_master_key".to_string(),
client: Client::new(),
})
}
/// Wait for the proxy to be ready.
async fn wait_for_ready(&self) -> anyhow::Result<()> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
while tokio::time::Instant::now() < deadline {
match self
.client
.get(format!("{}/health", self.proxy_url))
.send()
.await
{
Ok(resp) if resp.status().is_success() => return Ok(()),
_ => sleep(Duration::from_millis(100)).await,
}
}
anyhow::bail!("Proxy did not become ready in time")
}
/// Create an index.
async fn create_index(&self, uid: &str) -> anyhow::Result<()> {
let body = json!({
"uid": uid,
"primaryKey": "id"
});
let resp = self
.client
.post(format!("{}/indexes", self.proxy_url))
.header("Authorization", format!("Bearer {}", self.master_key))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
anyhow::bail!("Failed to create index: {}", resp.status());
}
// Wait for index to be created
self.wait_for_index(uid).await
}
/// Wait for an index to exist.
async fn wait_for_index(&self, uid: &str) -> anyhow::Result<()> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
while tokio::time::Instant::now() < deadline {
match self
.client
.get(format!("{}/indexes/{}", self.proxy_url, uid))
.header("Authorization", format!("Bearer {}", self.master_key))
.send()
.await
{
Ok(resp) if resp.status().is_success() => return Ok(()),
_ => sleep(Duration::from_millis(100)).await,
}
}
anyhow::bail!("Index {uid} did not become ready")
}
/// Add documents to an index.
async fn add_documents(&self, uid: &str, documents: Vec<Value>) -> anyhow::Result<Value> {
let resp = self
.client
.post(format!("{}/indexes/{}/documents", self.proxy_url, uid))
.header("Authorization", format!("Bearer {}", self.master_key))
.json(&documents)
.send()
.await?;
if !resp.status().is_success() {
anyhow::bail!("Failed to add documents: {}", resp.status());
}
Ok(resp.json().await?)
}
/// Search an index.
async fn search(&self, uid: &str, query: &serde_json::Value) -> anyhow::Result<Value> {
let resp = self
.client
.post(format!("{}/indexes/{}/search", self.proxy_url, uid))
.header("Authorization", format!("Bearer {}", self.master_key))
.json(query)
.send()
.await?;
if !resp.status().is_success() {
anyhow::bail!("Search failed: {}", resp.status());
}
Ok(resp.json().await?)
}
/// Get a document by ID.
async fn get_document(&self, uid: &str, id: &str) -> anyhow::Result<Value> {
let resp = self
.client
.get(format!(
"{}/indexes/{}/documents/{}",
self.proxy_url, uid, id
))
.header("Authorization", format!("Bearer {}", self.master_key))
.send()
.await?;
if !resp.status().is_success() {
anyhow::bail!("Failed to get document: {}", resp.status());
}
Ok(resp.json().await?)
}
}
#[tokio::test]
#[ignore] // Requires docker
async fn test_1000_documents_indexed_and_retrievable() {
let setup = TestSetup::new().await.expect("Failed to setup test");
setup.wait_for_ready().await.expect("Proxy not ready");
let index_uid = "test_1000_docs";
// Create index
setup
.create_index(index_uid)
.await
.expect("Failed to create index");
// Generate 1000 documents
let documents: Vec<Value> = (0..1000)
.map(|i| {
json!({
"id": format!("doc-{:04}", i),
"title": format!("Document {}", i),
"content": format!("Content for document {}", i),
})
})
.collect();
// Add documents
let _task = setup
.add_documents(index_uid, documents)
.await
.expect("Failed to add documents");
// Wait for task to complete
sleep(Duration::from_secs(2)).await;
// Verify each document is retrievable by ID
for i in 0..1000 {
let doc_id = format!("doc-{i:04}");
let doc = setup
.get_document(index_uid, &doc_id)
.await
.unwrap_or_else(|_| panic!("Failed to get document {doc_id}"));
assert_eq!(doc.get("id").unwrap().as_str().unwrap(), doc_id);
assert_eq!(
doc.get("title").unwrap().as_str().unwrap(),
format!("Document {i}")
);
}
}
#[tokio::test]
#[ignore] // Requires docker
async fn test_unique_keyword_search_finds_each_doc_once() {
let setup = TestSetup::new().await.expect("Failed to setup test");
setup.wait_for_ready().await.expect("Proxy not ready");
let index_uid = "test_unique_search";
// Create index
setup
.create_index(index_uid)
.await
.expect("Failed to create index");
// Add documents with unique keywords
let documents: Vec<Value> = (0..100)
.map(|i| {
json!({
"id": format!("doc-{:03}", i),
"keyword": format!("keyword{:03}", i),
"title": format!("Document {}", i),
})
})
.collect();
setup
.add_documents(index_uid, documents)
.await
.expect("Failed to add documents");
sleep(Duration::from_secs(2)).await;
// Search for each unique keyword and verify exactly one result
for i in 0..100 {
let keyword = format!("keyword{i:03}");
let result = setup
.search(index_uid, &json!({"q": keyword}))
.await
.unwrap_or_else(|_| panic!("Search failed for {keyword}"));
let hits = result.get("hits").unwrap().as_array().unwrap();
assert_eq!(
hits.len(),
1,
"Expected exactly 1 hit for {}, got {}",
keyword,
hits.len()
);
let doc_id = format!("doc-{i:03}");
assert_eq!(hits[0].get("id").unwrap().as_str().unwrap(), doc_id);
}
}
#[tokio::test]
#[ignore] // Requires docker
async fn test_facet_aggregation_sums_correctly() {
let setup = TestSetup::new().await.expect("Failed to setup test");
setup.wait_for_ready().await.expect("Proxy not ready");
let index_uid = "test_facets";
// Create index with filterable attributes
setup
.create_index(index_uid)
.await
.expect("Failed to create index");
// Configure filterable attributes
let filterable = json!({"filterableAttributes": ["color"]});
let resp = setup
.client
.patch(format!(
"{}/indexes/{}/settings",
setup.proxy_url, index_uid
))
.header("Authorization", format!("Bearer {}", setup.master_key))
.json(&filterable)
.send()
.await
.expect("Failed to set filterable attributes");
assert!(resp.status().is_success());
// Add documents with color facets
let colors = ["red", "green", "blue"];
let documents: Vec<Value> = (0..300)
.map(|i| {
json!({
"id": format!("doc-{:03}", i),
"color": colors[i % 3],
"value": i,
})
})
.collect();
setup
.add_documents(index_uid, documents)
.await
.expect("Failed to add documents");
sleep(Duration::from_secs(2)).await;
// Search with facets
let result = setup
.search(
index_uid,
&json!({
"q": "",
"facets": ["color"]
}),
)
.await
.expect("Search failed");
// Verify facet distribution sums correctly
let facet_distribution = result
.get("facetDistribution")
.unwrap()
.as_object()
.unwrap();
let color_dist = facet_distribution
.get("color")
.unwrap()
.as_object()
.unwrap();
assert_eq!(color_dist.get("red").unwrap().as_u64().unwrap(), 100);
assert_eq!(color_dist.get("green").unwrap().as_u64().unwrap(), 100);
assert_eq!(color_dist.get("blue").unwrap().as_u64().unwrap(), 100);
}
#[tokio::test]
#[ignore] // Requires docker
async fn test_offset_limit_preserves_global_ordering() {
let setup = TestSetup::new().await.expect("Failed to setup test");
setup.wait_for_ready().await.expect("Proxy not ready");
let index_uid = "test_pagination";
setup
.create_index(index_uid)
.await
.expect("Failed to create index");
// Add documents with ordered titles
let documents: Vec<Value> = (0..100)
.map(|i| {
json!({
"id": format!("doc-{:02}", i),
"title": format!("Title{:02}", i),
})
})
.collect();
setup
.add_documents(index_uid, documents)
.await
.expect("Failed to add documents");
sleep(Duration::from_secs(2)).await;
// Fetch all documents in pages
let mut all_ids = Vec::new();
for page in 0..10 {
let result = setup
.search(
index_uid,
&json!({
"q": "",
"offset": page * 10,
"limit": 10
}),
)
.await
.expect("Search failed");
let hits = result.get("hits").unwrap().as_array().unwrap();
for hit in hits {
let id = hit.get("id").unwrap().as_str().unwrap().to_string();
all_ids.push(id);
}
}
// Verify we got all 100 documents in order
assert_eq!(all_ids.len(), 100);
for (i, id) in all_ids.iter().enumerate() {
let expected = format!("doc-{i:02}");
assert_eq!(
id, &expected,
"Document at position {i} should be {expected}, got {id}"
);
}
}
#[tokio::test]
#[ignore] // Requires docker
async fn test_write_with_one_group_down_succeeds_on_remaining() {
let setup = TestSetup::new().await.expect("Failed to setup test");
setup.wait_for_ready().await.expect("Proxy not ready");
let index_uid = "test_degraded_write";
setup
.create_index(index_uid)
.await
.expect("Failed to create index");
// Stop one replica group (nodes 0 and 2 are in group 0, node 1 is in group 1)
// In this test, we simulate node failure by marking them as unhealthy
// In a real scenario, you'd actually stop the container
// For now, we'll just verify that writes succeed even when some nodes are down
// by checking that the X-Miroir-Degraded header is set correctly
let documents: Vec<Value> = (0..10)
.map(|i| {
json!({
"id": format!("doc-{:02}", i),
"value": i,
})
})
.collect();
let resp = setup
.client
.post(format!(
"{}/indexes/{}/documents",
setup.proxy_url, index_uid
))
.header("Authorization", format!("Bearer {}", setup.master_key))
.json(&documents)
.send()
.await
.expect("Failed to add documents");
// Check for X-Miroir-Degraded header if any group was degraded
let degraded_header = resp.headers().get("X-Miroir-Degraded");
// The write should succeed regardless
assert!(resp.status().is_success() || resp.status().as_u16() == 503);
// If degraded header is present, verify its format
if let Some(header) = degraded_header {
let header_value = header.to_str().unwrap();
assert!(
header_value.starts_with("shards="),
"X-Miroir-Degraded should start with 'shards='"
);
}
}
#[tokio::test]
#[ignore] // Requires docker
async fn test_error_format_parity_with_meilisearch() {
let setup = TestSetup::new().await.expect("Failed to setup test");
setup.wait_for_ready().await.expect("Proxy not ready");
// Test various error conditions and verify format
// 1. Invalid request (empty document batch)
let empty_docs: [Value; 0] = [];
let resp = setup
.client
.post(format!("{}/indexes/test/documents", setup.proxy_url))
.header("Authorization", format!("Bearer {}", setup.master_key))
.json(&empty_docs)
.send()
.await
.expect("Request failed");
assert_eq!(resp.status().as_u16(), 400);
let error: Value = resp.json().await.expect("Failed to parse error");
assert!(
error.get("message").is_some(),
"Error should have 'message' field"
);
assert!(
error.get("code").is_some(),
"Error should have 'code' field"
);
assert!(
error.get("type").is_some(),
"Error should have 'type' field"
);
assert!(
error.get("link").is_some(),
"Error should have 'link' field"
);
// Verify error type is one of the known types
let error_type = error.get("type").unwrap().as_str().unwrap();
assert!(["invalid_request", "auth", "internal", "system"].contains(&error_type));
// 2. Not found (non-existent index)
let resp = setup
.client
.get(format!("{}/indexes/nonexistent", setup.proxy_url))
.header("Authorization", format!("Bearer {}", setup.master_key))
.send()
.await
.expect("Request failed");
assert!(resp.status().as_u16() == 404 || resp.status().as_u16() == 400);
// 3. Authentication error
let resp = setup
.client
.get(format!("{}/indexes/test", setup.proxy_url))
.header("Authorization", "Bearer invalid_key")
.send()
.await
.expect("Request failed");
assert!(resp.status().as_u16() == 401 || resp.status().as_u16() == 403);
}