yams/tests/unit/app/services/document_service_test.cpp

622 lines
21 KiB
C++

#include <chrono>
#include <filesystem>
#include <fstream>
#include <future>
#include <memory>
#include <span>
#include <catch2/catch_test_macros.hpp>
#include <yams/api/content_store_builder.h>
#include <yams/app/services/services.hpp>
#include <yams/app/services/session_service.hpp>
#include <yams/core/cpp23_features.hpp>
#include <yams/metadata/connection_pool.h>
#include <yams/metadata/database.h>
#include <yams/metadata/metadata_repository.h>
#include <yams/metadata/migration.h>
#include <yams/search/search_engine.h>
using namespace yams;
using namespace yams::app::services;
using namespace yams::metadata;
using namespace yams::api;
struct DocumentFixture {
DocumentFixture() {
setupTestEnvironment();
setupDatabase();
setupServices();
setupTestDocuments();
}
~DocumentFixture() {
documentService_.reset();
searchEngine_.reset();
contentStore_.reset();
// Clear all shared_ptr refs to metadataRepo BEFORE pool/database
// destruction. appContext_ holds its own copy of the shared_ptr,
// and C++ destroys members in reverse declaration order — but
// appContext_ is declared AFTER pool_, so pool_ would be destroyed
// while appContext_ still owns a MetadataRepository that references it.
metadataRepo_.reset();
appContext_ = {}; // drops shared_ptr copies held by AppContext
if (pool_) {
pool_->shutdown();
pool_.reset();
}
if (database_) {
database_->close();
database_.reset();
}
if (!testDir_.empty() && std::filesystem::exists(testDir_)) {
std::error_code ec;
std::filesystem::remove_all(testDir_, ec);
}
}
void setupTestEnvironment() {
auto pid = std::to_string(::getpid());
auto timestamp =
std::to_string(std::chrono::system_clock::now().time_since_epoch().count());
testDir_ = std::filesystem::temp_directory_path() /
("document_service_test_" + pid + "_" + timestamp);
std::error_code ec;
std::filesystem::create_directories(testDir_, ec);
REQUIRE_FALSE(ec);
}
void setupDatabase() {
dbPath_ = std::filesystem::absolute(testDir_ / "test.db");
database_ = std::make_unique<Database>();
auto openResult = database_->open(dbPath_.string(), ConnectionMode::Create);
REQUIRE(openResult);
ConnectionPoolConfig poolConfig;
poolConfig.maxConnections = 4;
pool_ = std::make_unique<ConnectionPool>(dbPath_.string(), poolConfig);
metadataRepo_ = std::make_shared<MetadataRepository>(*pool_);
MigrationManager mm(*database_);
auto initResult = mm.initialize();
REQUIRE(initResult);
mm.registerMigrations(YamsMetadataMigrations::getAllMigrations());
auto migrateResult = mm.migrate();
REQUIRE(migrateResult);
}
void setupServices() {
ContentStoreBuilder builder;
auto storeResult = builder.withStoragePath(testDir_ / "storage")
.withChunkSize(65536)
.withCompression(true)
.withDeduplication(true)
.build();
REQUIRE(storeResult);
auto& uniqueStore = storeResult.value();
contentStore_ = std::shared_ptr<IContentStore>(
const_cast<std::unique_ptr<IContentStore>&>(uniqueStore).release());
searchEngine_ = nullptr;
appContext_.store = contentStore_;
appContext_.metadataRepo = metadataRepo_;
appContext_.searchEngine = searchEngine_;
documentService_ = makeDocumentService(appContext_);
}
void setupTestDocuments() {
auto testFile1 = testDir_ / "test1.txt";
auto testFile2 = testDir_ / "test2.md";
auto testFile3 = testDir_ / "libsample.so";
std::ofstream{testFile1} << "Test document content for retrieval testing";
std::ofstream{testFile2} << "Another test document with different content";
{
std::ofstream binary(testFile3, std::ios::binary);
const unsigned char elfBytes[] = {0x7F, 0x45, 0x4C, 0x46, 0x02, 0x01, 0x01, 0x00};
binary.write(reinterpret_cast<const char*>(elfBytes), sizeof(elfBytes));
}
StoreDocumentRequest storeReq1;
storeReq1.path = testFile1.string();
storeReq1.tags = {"test", "document"};
StoreDocumentRequest storeReq2;
storeReq2.path = testFile2.string();
storeReq2.tags = {"test", "markdown"};
StoreDocumentRequest storeReq3;
storeReq3.path = testFile3.string();
storeReq3.tags = {"test", "binary"};
auto result1 = documentService_->store(storeReq1);
auto result2 = documentService_->store(storeReq2);
auto result3 = documentService_->store(storeReq3);
REQUIRE(result1);
REQUIRE(result2);
REQUIRE(result3);
testHash1_ = result1.value().hash;
testHash2_ = result2.value().hash;
}
std::string createTestContent(std::string_view identifier) {
return std::string("Test content for document: ") + std::string(identifier) +
" created at " +
std::to_string(std::chrono::system_clock::now().time_since_epoch().count());
}
std::filesystem::path testDir_;
std::filesystem::path dbPath_;
std::unique_ptr<Database> database_;
std::unique_ptr<ConnectionPool> pool_;
std::shared_ptr<MetadataRepository> metadataRepo_;
std::shared_ptr<IContentStore> contentStore_;
std::shared_ptr<search::SearchEngine> searchEngine_;
AppContext appContext_;
std::shared_ptr<IDocumentService> documentService_;
std::string testHash1_;
std::string testHash2_;
};
TEST_CASE("DocumentService - Listing", "[document][service][listing]") {
DocumentFixture fixture;
SECTION("List all documents") {
ListDocumentsRequest request;
request.limit = 100;
auto result = fixture.documentService_->list(request);
REQUIRE(result);
CHECK(result.value().documents.size() >= 2);
}
SECTION("List with limit") {
ListDocumentsRequest request;
request.limit = 1;
auto result = fixture.documentService_->list(request);
REQUIRE(result);
CHECK(result.value().documents.size() == 1);
}
SECTION("List with offset") {
ListDocumentsRequest request;
request.limit = 100;
request.offset = 1;
auto result = fixture.documentService_->list(request);
REQUIRE(result);
CHECK(result.value().documents.size() >= 1);
}
SECTION("List bare directory returns descendants") {
namespace fs = std::filesystem;
auto normalizedPathString = [](const fs::path& path) {
std::error_code ec;
auto resolved = fs::weakly_canonical(path, ec);
if (ec) {
resolved = path.lexically_normal();
}
auto normalized = resolved.generic_string();
#ifdef _WIN32
std::transform(normalized.begin(), normalized.end(), normalized.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
#endif
return normalized;
};
fs::create_directories(fixture.testDir_ / "nested" / "deep");
std::ofstream(fixture.testDir_ / "nested" / "deep" / "descendant.txt")
<< "descendant content";
StoreDocumentRequest nestedReq;
nestedReq.path = (fixture.testDir_ / "nested" / "deep" / "descendant.txt").string();
nestedReq.tags = {"nested"};
auto nestedStore = fixture.documentService_->store(nestedReq);
REQUIRE(nestedStore);
const auto expectedPath =
normalizedPathString(fixture.testDir_ / "nested" / "deep" / "descendant.txt");
ListDocumentsRequest request;
request.pattern = (fixture.testDir_ / "nested").string();
request.limit = 100;
auto result = fixture.documentService_->list(request);
REQUIRE(result);
bool foundDescendant = false;
for (const auto& doc : result.value().documents) {
if (normalizedPathString(doc.path) == expectedPath) {
foundDescendant = true;
break;
}
}
CHECK(foundDescendant);
}
SECTION("List with tag filter") {
ListDocumentsRequest request;
request.limit = 100;
request.tags = {"markdown"};
auto result = fixture.documentService_->list(request);
REQUIRE(result);
bool foundMarkdown = false;
for (const auto& doc : result.value().documents) {
if (std::find(doc.tags.begin(), doc.tags.end(), "markdown") != doc.tags.end()) {
foundMarkdown = true;
break;
}
}
CHECK(foundMarkdown);
}
SECTION("List with type filter") {
ListDocumentsRequest request;
request.limit = 100;
request.type = "text";
auto result = fixture.documentService_->list(request);
REQUIRE(result);
}
SECTION("List with executable type filter") {
ListDocumentsRequest request;
request.limit = 100;
request.type = "executable";
auto result = fixture.documentService_->list(request);
REQUIRE(result);
REQUIRE_FALSE(result.value().documents.empty());
bool foundSharedObject = false;
for (const auto& doc : result.value().documents) {
CHECK(doc.fileType == "executable");
if (doc.fileName == "libsample.so") {
foundSharedObject = true;
CHECK(doc.mimeType == "application/x-sharedlib");
}
}
CHECK(foundSharedObject);
}
SECTION("List recent documents") {
ListDocumentsRequest request;
request.limit = 5;
request.recent = 5;
auto result = fixture.documentService_->list(request);
REQUIRE(result);
CHECK(result.value().documents.size() >= 2);
}
}
TEST_CASE("DocumentService - Retrieval", "[document][service][retrieval]") {
DocumentFixture fixture;
SECTION("Retrieve document by hash") {
REQUIRE_FALSE(fixture.testHash1_.empty());
RetrieveDocumentRequest request;
request.hash = fixture.testHash1_;
auto result = fixture.documentService_->retrieve(request);
REQUIRE(result);
REQUIRE(result.value().document.has_value());
CHECK(result.value().document->hash == fixture.testHash1_);
}
SECTION("Retrieve with compressed content and metadata") {
REQUIRE_FALSE(fixture.testHash1_.empty());
RetrieveDocumentRequest request;
request.hash = fixture.testHash1_;
request.includeContent = true;
auto result = fixture.documentService_->retrieve(request);
REQUIRE(result);
REQUIRE(result.value().document.has_value());
CHECK(result.value().document->hash == fixture.testHash1_);
CHECK(result.value().document->content.has_value());
}
SECTION("Retrieve document by name") {
RetrieveDocumentRequest request;
request.name = (fixture.testDir_ / "test1.txt").string();
auto result = fixture.documentService_->retrieve(request);
REQUIRE(result);
REQUIRE(result.value().document.has_value());
CHECK_FALSE(result.value().document->hash.empty());
}
SECTION("Retrieve non-existent document") {
RetrieveDocumentRequest request;
request.hash = "nonexistent_hash_12345";
auto result = fixture.documentService_->retrieve(request);
CHECK_FALSE(result);
}
}
TEST_CASE("DocumentService - Name Resolution", "[document][service][resolve]") {
DocumentFixture fixture;
SECTION("Resolve name to hash via tree lookup") {
auto path = (fixture.testDir_ / "test1.txt").string();
RetrieveDocumentRequest request;
request.name = path;
auto result = fixture.documentService_->retrieve(request);
REQUIRE(result);
REQUIRE(result.value().document.has_value());
CHECK_FALSE(result.value().document->hash.empty());
}
SECTION("Resolve by hash directly") {
REQUIRE_FALSE(fixture.testHash1_.empty());
RetrieveDocumentRequest request;
request.hash = fixture.testHash1_;
auto result = fixture.documentService_->retrieve(request);
REQUIRE(result);
REQUIRE(result.value().document.has_value());
CHECK(result.value().document->hash == fixture.testHash1_);
}
SECTION("Resolve path suffix") {
RetrieveDocumentRequest request;
request.name = "test1.txt";
auto result = fixture.documentService_->retrieve(request);
REQUIRE(result);
REQUIRE(result.value().document.has_value());
CHECK_FALSE(result.value().document->hash.empty());
}
SECTION("Resolve not found") {
RetrieveDocumentRequest request;
request.name = "nonexistent_file.txt";
auto result = fixture.documentService_->retrieve(request);
CHECK_FALSE(result);
}
SECTION("Resolve with normalized path separators") {
auto pathWithMixedSeparators = fixture.testDir_.string() + "/test1.txt";
RetrieveDocumentRequest request;
request.name = pathWithMixedSeparators;
auto result = fixture.documentService_->retrieve(request);
REQUIRE(result);
REQUIRE(result.value().document.has_value());
}
SECTION("Resolve with empty name and hash") {
RetrieveDocumentRequest request;
auto result = fixture.documentService_->retrieve(request);
CHECK_FALSE(result);
}
SECTION("Resolve relative path from cwd") {
auto originalCwd = std::filesystem::current_path();
std::filesystem::current_path(fixture.testDir_);
RetrieveDocumentRequest request;
request.name = "test1.txt";
auto result = fixture.documentService_->retrieve(request);
std::filesystem::current_path(originalCwd);
REQUIRE(result);
REQUIRE(result.value().document.has_value());
CHECK_FALSE(result.value().document->hash.empty());
}
}
TEST_CASE("DocumentService - Deletion", "[document][service][deletion]") {
DocumentFixture fixture;
SECTION("Delete document by name") {
DeleteByNameRequest request;
request.name = (fixture.testDir_ / "test2.md").string();
auto result = fixture.documentService_->deleteByName(request);
REQUIRE(result);
CHECK_FALSE(result.value().deleted.empty());
}
}
TEST_CASE("DocumentService - Error Handling", "[document][service][errors]") {
DocumentFixture fixture;
SECTION("Emit helpful message on file not found with commas") {
class NotFoundStore : public IContentStore {
public:
Result<StoreResult> store(const std::filesystem::path& path, const ContentMetadata&,
api::ProgressCallback) override {
return Error{ErrorCode::FileNotFound,
std::string("File not found: ") + path.string()};
}
Result<RetrieveResult> retrieve(const std::string&, const std::filesystem::path&,
api::ProgressCallback) override {
return ErrorCode::NotImplemented;
}
Result<StoreResult> storeStream(std::istream&, const ContentMetadata&,
api::ProgressCallback) override {
return ErrorCode::NotImplemented;
}
Result<RetrieveResult> retrieveStream(const std::string&, std::ostream&,
api::ProgressCallback) override {
return ErrorCode::NotImplemented;
}
Result<StoreResult> storeBytes(std::span<const std::byte>,
const ContentMetadata&) override {
return ErrorCode::NotImplemented;
}
Result<std::vector<std::byte>> retrieveBytes(const std::string&) override {
return ErrorCode::NotImplemented;
}
Result<std::vector<std::byte>> retrieveBytesPrefix(const std::string&,
std::size_t) override {
return ErrorCode::NotImplemented;
}
Result<RawContent> retrieveRaw(const std::string&) override {
return ErrorCode::NotImplemented;
}
std::future<Result<RawContent>> retrieveRawAsync(const std::string&) override {
return std::async(std::launch::deferred,
[] { return Result<RawContent>(ErrorCode::NotImplemented); });
}
Result<bool> exists(const std::string&) const override {
return ErrorCode::NotImplemented;
}
Result<bool> remove(const std::string&) override { return ErrorCode::NotImplemented; }
Result<ContentMetadata> getMetadata(const std::string&) const override {
return ErrorCode::NotImplemented;
}
Result<void> updateMetadata(const std::string&, const ContentMetadata&) override {
return ErrorCode::NotImplemented;
}
std::vector<Result<StoreResult>>
storeBatch(const std::vector<std::filesystem::path>&,
const std::vector<ContentMetadata>&) override {
return {};
}
std::vector<Result<bool>> removeBatch(const std::vector<std::string>&) override {
return {};
}
ContentStoreStats getStats() const override { return {}; }
HealthStatus checkHealth() const override { return {}; }
Result<void> verify(api::ProgressCallback) override {
return ErrorCode::NotImplemented;
}
Result<void> compact(api::ProgressCallback) override {
return ErrorCode::NotImplemented;
}
Result<void> garbageCollect(api::ProgressCallback) override {
return ErrorCode::NotImplemented;
}
};
AppContext ctx;
ctx.store = std::make_shared<NotFoundStore>();
auto svc = makeDocumentService(ctx);
StoreDocumentRequest req;
req.path = "a,b,c";
auto r = svc->store(req);
REQUIRE_FALSE(r);
CHECK(r.error().code == ErrorCode::FileNotFound);
auto msg = r.error().message;
CHECK(msg.find("a,b,c") != std::string::npos);
CHECK(msg.find("commas") != std::string::npos);
}
}
TEST_CASE("DocumentService - Performance", "[document][service][performance]") {
DocumentFixture fixture;
SECTION("List performance") {
auto start = std::chrono::steady_clock::now();
ListDocumentsRequest request;
request.limit = 100;
auto result = fixture.documentService_->list(request);
auto end = std::chrono::steady_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
REQUIRE(result);
CHECK(duration.count() < 1000);
}
SECTION("Retrieval performance") {
REQUIRE_FALSE(fixture.testHash1_.empty());
auto start = std::chrono::steady_clock::now();
RetrieveDocumentRequest request;
request.hash = fixture.testHash1_;
auto result = fixture.documentService_->retrieve(request);
auto end = std::chrono::steady_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
REQUIRE(result);
CHECK(duration.count() < 1000);
}
}
TEST_CASE("DocumentService - Integration", "[document][service][integration]") {
DocumentFixture fixture;
SECTION("Search integration") {
ListDocumentsRequest request;
request.limit = 100;
auto result = fixture.documentService_->list(request);
REQUIRE(result);
CHECK(result.value().documents.size() >= 2);
}
SECTION("Service context integration") {
REQUIRE(fixture.documentService_);
CHECK(fixture.appContext_.store);
CHECK(fixture.appContext_.metadataRepo);
}
SECTION("Inline extensionless binary content is not forced to text") {
StoreDocumentRequest request;
request.name = "88f051b8c01feda0b07cc00112233445";
request.content = "?<<?"
"?Y?"
"?<[o+?g?<R`g=?#!={?;=?t?:}?";
auto stored = fixture.documentService_->store(request);
REQUIRE(stored);
auto doc = fixture.metadataRepo_->getDocumentByHash(stored.value().hash);
REQUIRE(doc);
REQUIRE(doc.value().has_value());
CHECK(doc.value()->mimeType == "application/octet-stream");
CHECK_FALSE(doc.value()->contentExtracted);
}
}