yams/tests/integration/search/hybrid_search_integration_test.cpp
2026-01-24 11:49:48 -05:00

275 lines
10 KiB
C++

// Integration tests for hybrid search functionality requiring full daemon infrastructure
// Covers semantic search, hybrid search with real embedding provider and vector DB
#include "tests/integration/daemon/test_async_helpers.h"
#include "tests/integration/daemon/test_daemon_harness.h"
#include <catch2/catch_test_macros.hpp>
#include <yams/daemon/client/daemon_client.h>
// Windows daemon IPC tests are unstable due to socket shutdown race conditions
#ifdef _WIN32
#define SKIP_ON_WINDOWS_DAEMON_SHUTDOWN() \
SKIP("Daemon IPC tests unstable on Windows - see windows-daemon-ipc-plan.md")
#else
#define SKIP_ON_WINDOWS_DAEMON_SHUTDOWN() ((void)0)
#endif
using namespace yams::daemon;
using namespace std::chrono_literals;
namespace yams::test {
// Fixture for integration tests requiring full daemon with hybrid search infrastructure
class HybridSearchIntegrationFixture {
public:
HybridSearchIntegrationFixture() {
// Start daemon with full search infrastructure
bool started = harness_.start(std::chrono::seconds(30));
if (!started) {
throw std::runtime_error("Failed to start daemon for hybrid search integration tests");
}
// Create daemon client
ClientConfig clientCfg;
clientCfg.socketPath = harness_.socketPath();
clientCfg.connectTimeout = 5s;
clientCfg.autoStart = false;
spdlog::info("[HybridSearchFixture] Creating DaemonClient for socket: {}",
clientCfg.socketPath.string());
client_ = std::make_unique<DaemonClient>(clientCfg);
// Initial delay for GlobalIOContext threads to stabilize
std::this_thread::sleep_for(std::chrono::milliseconds(200));
// Connect to daemon with retry logic for GlobalIOContext timing
spdlog::info("[HybridSearchFixture] Calling client_->connect()...");
Result<void> connectResult;
constexpr int maxRetries = 3;
for (int attempt = 0; attempt < maxRetries; ++attempt) {
connectResult = cli::run_sync(client_->connect(), 5s);
if (connectResult) {
break;
}
spdlog::warn("[HybridSearchFixture] connect() attempt {} failed: {}, retrying...",
attempt + 1, connectResult.error().message);
std::this_thread::sleep_for(std::chrono::milliseconds(200));
}
if (!connectResult) {
spdlog::error("[HybridSearchFixture] connect() failed after {} attempts: {}",
maxRetries, connectResult.error().message);
throw std::runtime_error("Failed to connect to daemon: " +
connectResult.error().message);
}
spdlog::info("[HybridSearchFixture] connect() succeeded");
// Verify daemon is ready (search infrastructure available)
auto statusResult = cli::run_sync(client_->status(), 5s);
if (!statusResult) {
throw std::runtime_error("Failed to get daemon status: " +
statusResult.error().message);
}
// Note: Mock provider may show embeddings as unavailable but daemon is ready
// This is expected for integration tests using mock model provider
}
~HybridSearchIntegrationFixture() {
// Cleanup: disconnect and stop daemon
if (client_) {
client_->disconnect();
}
harness_.stop();
}
// Execute search via daemon client
Result<SearchResponse> executeSearch(const SearchRequest& req) {
return cli::run_sync(client_->search(req), 10s);
}
DaemonClient* client() { return client_.get(); }
const std::filesystem::path& dataDir() const { return harness_.dataDir(); }
private:
DaemonHarness harness_;
std::unique_ptr<daemon::DaemonClient> client_;
};
// =============================================================================
// Semantic Search Tests (require embedding provider and vector DB)
// =============================================================================
TEST_CASE("SemanticSearch - Infrastructure availability", "[integration][search][semantic]") {
SKIP_ON_WINDOWS_DAEMON_SHUTDOWN();
HybridSearchIntegrationFixture fixture;
// Test that daemon is running and can respond to semantic search requests
// With mock provider, semantic search may return "unavailable" but should not crash
SearchRequest req;
req.query = "indexing pipeline";
req.searchType = "semantic";
req.limit = 10;
req.timeout = 2s; // Short timeout for mock provider
auto result = fixture.executeSearch(req);
// With mock provider, semantic search infrastructure is unavailable
// This test verifies the daemon handles semantic requests gracefully
// Real test would verify actual embedding-based search results
if (!result) {
// Expected: InvalidState when embeddings unavailable
REQUIRE(result.error().code == ErrorCode::InvalidState);
REQUIRE(result.error().message.find("not ready") != std::string::npos);
} else {
// If embeddings become available, verify response structure
REQUIRE(result.value().results.size() <= 10);
}
}
TEST_CASE("SemanticSearch - Similarity threshold filtering", "[integration][search][semantic]") {
SKIP_ON_WINDOWS_DAEMON_SHUTDOWN();
HybridSearchIntegrationFixture fixture;
// Test semantic search with high similarity threshold
SearchRequest req;
req.query = "document indexing";
req.searchType = "semantic";
req.similarity = 0.9f; // Very high threshold - only very similar results
req.limit = 10;
req.timeout = 2s; // Short timeout for mock provider
auto result = fixture.executeSearch(req);
// With mock provider, expect InvalidState
if (!result) {
REQUIRE(result.error().code == ErrorCode::InvalidState);
} else {
// If embeddings available, verify all results meet threshold
for (const auto& hit : result.value().results) {
REQUIRE(hit.score >= 0.9f);
}
}
}
TEST_CASE("SemanticSearch - Empty query validation",
"[integration][search][semantic][validation]") {
SKIP_ON_WINDOWS_DAEMON_SHUTDOWN();
HybridSearchIntegrationFixture fixture;
// Empty query should fail validation at daemon level
SearchRequest req;
req.query = "";
req.searchType = "semantic";
req.limit = 10;
req.timeout = 2s; // Short timeout
auto result = fixture.executeSearch(req);
// Should reject empty query
REQUIRE_FALSE(result);
REQUIRE(result.error().message.find("required") != std::string::npos);
}
TEST_CASE("SemanticSearch - Very long query handling", "[integration][search][semantic][edge]") {
SKIP_ON_WINDOWS_DAEMON_SHUTDOWN();
HybridSearchIntegrationFixture fixture;
// Test with very long query (stress test embedding provider)
std::string longQuery(5000, 'a');
longQuery += " indexing pipeline search";
SearchRequest req;
req.query = longQuery;
req.searchType = "semantic";
req.limit = 10;
req.timeout = 2s; // Short timeout for mock provider
auto result = fixture.executeSearch(req);
// Should handle gracefully (either succeed or return InvalidState)
if (!result) {
// With mock provider or query too long
REQUIRE((result.error().code == ErrorCode::InvalidState ||
result.error().code == ErrorCode::InvalidArgument));
} else {
REQUIRE(result.value().results.size() <= 10);
}
}
// =============================================================================
// Hybrid Search Tests (require keyword + semantic fusion)
// =============================================================================
TEST_CASE("HybridSearch - RRF fusion with real infrastructure", "[integration][search][hybrid]") {
SKIP_ON_WINDOWS_DAEMON_SHUTDOWN();
HybridSearchIntegrationFixture fixture;
// Test Reciprocal Rank Fusion with both keyword and semantic results
SearchRequest req;
req.query = "indexing pipeline";
req.searchType = "hybrid";
req.limit = 10;
req.timeout = 2s; // Short timeout for mock provider
auto result = fixture.executeSearch(req);
// With mock provider, hybrid search falls back to keyword-only
if (!result) {
REQUIRE(result.error().code == ErrorCode::InvalidState);
} else {
// Should return results (keyword fallback or actual hybrid)
REQUIRE(result.value().results.size() <= 10);
// Results should be ranked by RRF score if hybrid engine available
if (result.value().results.size() > 1) {
// Verify descending score order
for (size_t i = 1; i < result.value().results.size(); ++i) {
REQUIRE(result.value().results[i - 1].score >= result.value().results[i].score);
}
}
}
}
TEST_CASE("HybridSearch - Keyword-only fallback path", "[integration][search][hybrid][fallback]") {
SKIP_ON_WINDOWS_DAEMON_SHUTDOWN();
HybridSearchIntegrationFixture fixture;
// When vector search unavailable, hybrid should fall back to keyword-only
SearchRequest req;
req.query = "pipeline";
req.searchType = "hybrid";
req.limit = 5;
req.timeout = 2s; // Short timeout for mock provider
auto result = fixture.executeSearch(req);
// Should succeed with keyword fallback or return InvalidState
if (result) {
REQUIRE(result.value().results.size() <= 5);
} else {
REQUIRE(result.error().code == ErrorCode::InvalidState);
}
}
TEST_CASE("HybridSearch - Engine unavailable detection", "[integration][search][hybrid][error]") {
SKIP_ON_WINDOWS_DAEMON_SHUTDOWN();
HybridSearchIntegrationFixture fixture;
// Test that hybrid search properly detects when hybrid engine is unavailable
SearchRequest req;
req.query = "pipeline";
req.searchType = "hybrid";
req.limit = 10;
req.timeout = 2s; // Short timeout for mock provider
auto result = fixture.executeSearch(req);
// Without hybrid engine, should return InvalidState error
if (!result) {
REQUIRE(result.error().code == ErrorCode::InvalidState);
REQUIRE(result.error().message.find("not ready") != std::string::npos);
} else {
// If hybrid engine becomes available, verify response structure
REQUIRE(result.value().results.size() <= 10);
}
}
} // namespace yams::test