|
|
@@ -1,4 +1,5 @@
|
|
|
#include "api_fixture.hpp"
|
|
|
+#include "embedding_cache.hpp"
|
|
|
using svapi::testutil::ApiFixture;
|
|
|
|
|
|
TEST_F(ApiFixture, HealthzPublic) { auto r = noAuth().Get("/healthz"); ASSERT_TRUE(r); EXPECT_EQ(r->status, 200); }
|
|
|
@@ -396,3 +397,58 @@ TEST_F(ApiFixture, DeleteCollectionPurgesVectorIndex) {
|
|
|
|
|
|
c.Delete((base + "/leak_test").c_str());
|
|
|
}
|
|
|
+
|
|
|
+// B.5: identical query_text searches should hit the embedding cache on the second call
|
|
|
+// (the upstream embedding endpoint is called exactly once for the same text).
|
|
|
+TEST_F(ApiFixture, EmbeddingCacheDeduplicatesIdenticalQueryText) {
|
|
|
+ // Clear the process-singleton cache to avoid cross-test pollution.
|
|
|
+ svapi::embeddingCache().clear();
|
|
|
+
|
|
|
+ // Spin up a counting mock embedding server.
|
|
|
+ std::atomic<int> embReqCount{0};
|
|
|
+ httplib::Server countingMock;
|
|
|
+ countingMock.Post("/v1/embeddings", [&](const httplib::Request&, httplib::Response& res) {
|
|
|
+ ++embReqCount;
|
|
|
+ nlohmann::json emb = nlohmann::json::array();
|
|
|
+ for (int i = 0; i < mockDim_; ++i) emb.push_back(0.1f * (i + 1));
|
|
|
+ res.set_content(nlohmann::json{{"data", {{{"embedding", emb}}}}}.dump(), "application/json");
|
|
|
+ });
|
|
|
+ int countingPort = countingMock.bind_to_any_port("127.0.0.1");
|
|
|
+ std::thread countingThread([&]{ countingMock.listen_after_bind(); });
|
|
|
+
|
|
|
+ // Point settings at the counting mock.
|
|
|
+ svapi::Settings s = *settings_->snapshot();
|
|
|
+ s.openaiApiBase = "http://127.0.0.1:" + std::to_string(countingPort);
|
|
|
+ s.openaiApiKey = "test";
|
|
|
+ settings_->save(s);
|
|
|
+
|
|
|
+ // Create a vector collection whose dimension matches the mock's returned vector length.
|
|
|
+ auto c = admin();
|
|
|
+ const std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(),
|
|
|
+ nlohmann::json{{"name","cache_test"},{"kind","vector"},{"vector_dimension",mockDim_}}.dump(),
|
|
|
+ "application/json")->status, 201);
|
|
|
+
|
|
|
+ // POST /search twice with the same query_text.
|
|
|
+ const std::string searchPath = base + "/cache_test/search";
|
|
|
+ const nlohmann::json searchBody = {{"query_text", "cache dedup test"}, {"top_k", 5}};
|
|
|
+
|
|
|
+ auto r1 = c.Post(searchPath.c_str(), searchBody.dump(), "application/json");
|
|
|
+ ASSERT_TRUE(r1); EXPECT_EQ(r1->status, 200);
|
|
|
+
|
|
|
+ auto r2 = c.Post(searchPath.c_str(), searchBody.dump(), "application/json");
|
|
|
+ ASSERT_TRUE(r2); EXPECT_EQ(r2->status, 200);
|
|
|
+
|
|
|
+ // The upstream mock must have been called exactly once (second search hit the cache).
|
|
|
+ EXPECT_EQ(embReqCount.load(), 1)
|
|
|
+ << "expected exactly 1 upstream embedding request for 2 identical query_text searches";
|
|
|
+
|
|
|
+ // Cleanup.
|
|
|
+ c.Delete((base + "/cache_test").c_str());
|
|
|
+ // Restore settings to the fixture's original mock so other tests are unaffected.
|
|
|
+ svapi::Settings s2 = *settings_->snapshot();
|
|
|
+ s2.openaiApiBase = "http://127.0.0.1:" + std::to_string(mockPort_);
|
|
|
+ settings_->save(s2);
|
|
|
+ countingMock.stop();
|
|
|
+ countingThread.join();
|
|
|
+}
|