// Catch2 migration of content_store_test.cpp with expanded test coverage // Epic: yams-3s4 | Migration: api tests #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace yams::api::test { // Catch2 assertion decomposition intentionally resembles chained comparisons to clang-tidy. // NOLINTBEGIN(bugprone-chained-comparison) namespace fs = std::filesystem; using namespace std::chrono_literals; // Test fixture helper struct ContentStoreFixture { fs::path testDir_; std::unique_ptr store_; ContentStoreFixture() { testDir_ = fs::temp_directory_path() / ("kronos_test_" + std::to_string(std::random_device{}())); fs::create_directories(testDir_); auto result = ContentStoreBuilder::createDefault(testDir_); REQUIRE(result.has_value()); store_ = std::move(result).value(); } ~ContentStoreFixture() { store_.reset(); std::error_code ec; fs::remove_all(testDir_, ec); } fs::path createTestFile(const std::string& name, const std::string& content) { auto path = testDir_ / name; std::ofstream file(path); file << content; file.close(); return path; } std::string readFile(const fs::path& path) { std::ifstream file(path); return std::string(std::istreambuf_iterator(file), std::istreambuf_iterator()); } }; class ScriptedStorageEngine final : public yams::storage::IStorageEngine { public: bool failStoresAsNotInitialized{false}; bool failManifestStore{false}; bool failManifestStoreAmbiguously{false}; bool failManifestStoreWithMismatchedDurableBytes{false}; size_t storeCalls{0}; size_t storeBatchCalls{0}; size_t removeCalls{0}; std::vector removedKeys; Result store(std::string_view hash, std::span data) override { ++storeCalls; const std::string key(hash); if (failStoresAsNotInitialized) { return Error{ErrorCode::NotInitialized, "scripted storage is not ready"}; } if (failManifestStore && key.ends_with(".manifest")) { if (failManifestStoreAmbiguously) { objects_[key] = std::vector(data.begin(), data.end()); return Error{ErrorCode::NetworkError, "scripted ambiguous manifest write"}; } if (failManifestStoreWithMismatchedDurableBytes) { auto durableBytes = std::vector(data.begin(), data.end()); if (durableBytes.empty()) { durableBytes.push_back(std::byte{0}); } else { durableBytes.front() ^= std::byte{0x01}; } objects_[key] = std::move(durableBytes); return Error{ErrorCode::NetworkError, "scripted ambiguous manifest write with mismatched durable bytes"}; } return Error{ErrorCode::WriteError, "scripted manifest write failure"}; } objects_[key] = std::vector(data.begin(), data.end()); return {}; } Result> retrieve(std::string_view hash) const override { auto it = objects_.find(std::string(hash)); if (it == objects_.end()) { return Error{ErrorCode::NotFound, "scripted object missing"}; } return it->second; } Result retrieveRaw(std::string_view hash) const override { auto data = retrieve(hash); if (!data) { return data.error(); } RawObject raw; raw.data = std::move(data).value(); return raw; } Result exists(std::string_view hash) const noexcept override { return objects_.contains(std::string(hash)); } Result remove(std::string_view hash) override { ++removeCalls; removedKeys.emplace_back(hash.data(), hash.size()); objects_.erase(std::string(hash)); return {}; } Result getBlockSize(std::string_view hash) const override { auto it = objects_.find(std::string(hash)); if (it == objects_.end()) { return Error{ErrorCode::NotFound, "scripted object missing"}; } return static_cast(it->second.size()); } std::future> storeAsync(std::string_view hash, std::span data) override { return std::async(std::launch::deferred, [this, key = std::string(hash), copy = std::vector(data.begin(), data.end())]() { return store(key, copy); }); } std::future>> retrieveAsync(std::string_view hash) const override { return std::async(std::launch::deferred, [this, key = std::string(hash)]() { return retrieve(key); }); } std::future> retrieveRawAsync(std::string_view hash) const override { return std::async(std::launch::deferred, [this, key = std::string(hash)]() { return retrieveRaw(key); }); } std::vector> storeBatch(const std::vector>>& items) override { ++storeBatchCalls; std::vector> results; results.reserve(items.size()); for (const auto& [hash, data] : items) { results.push_back(store(hash, data)); } return results; } yams::storage::StorageStats getStats() const noexcept override { yams::storage::StorageStats stats; stats.totalObjects = objects_.size(); uint64_t totalBytes = 0; for (const auto& [_, data] : objects_) { totalBytes += data.size(); } stats.totalBytes = totalBytes; return stats; } Result getStorageSize() const override { uint64_t totalBytes = 0; for (const auto& [_, data] : objects_) { totalBytes += data.size(); } return totalBytes; } Result> list(std::string_view = "") const override { std::vector keys; for (const auto& [k, _] : objects_) { keys.push_back(k); } return keys; } bool empty() const { return objects_.empty(); } private: std::unordered_map> objects_; }; class FailingFileChunker final : public yams::chunking::IChunker { public: size_t chunkFileCalls{0}; const yams::chunking::ChunkingConfig& getConfig() const override { return config_; } std::vector chunkFile(const fs::path&) override { ++chunkFileCalls; throw std::runtime_error("scripted file chunk failure"); } std::vector chunkData(yams::span data) override { std::vector copy(data.begin(), data.end()); return {yams::chunking::Chunk{.data = std::move(copy), .hash = yams::crypto::SHA256Hasher::hash(data), .offset = 0, .size = data.size()}}; } std::vector chunkDataLazy(yams::span data) override { return chunkData(data); } std::future>> chunkFileAsync(const fs::path&) override { return std::async(std::launch::deferred, []() -> Result> { return Error{ErrorCode::InternalError, "scripted file chunk failure"}; }); } void setProgressCallback(ProgressCallback) override {} private: yams::chunking::ChunkingConfig config_{}; }; class ScriptedReferenceCounter final : public yams::storage::IReferenceCounter { class Transaction; public: bool failCommit{false}; size_t beginCalls{0}; size_t commitCalls{0}; size_t rollbackCalls{0}; size_t queuedIncrements{0}; size_t queuedDecrements{0}; size_t immediateDecrements{0}; Result increment(std::string_view, size_t, size_t) override { return {}; } Result decrement(std::string_view) override { ++immediateDecrements; return {}; } Result getRefCount(std::string_view) const override { return uint64_t{0}; } Result hasReferences(std::string_view) const override { return false; } Result getStats() const override { return yams::storage::RefCountStats{}; } std::unique_ptr beginTransaction() override; private: class Transaction final : public ITransaction { public: explicit Transaction(ScriptedReferenceCounter& parent) : parent_(parent) {} void increment(std::string_view, size_t, size_t) override { if (!active_) { throw std::runtime_error("scripted transaction inactive"); } ++parent_.queuedIncrements; } void decrement(std::string_view) override { if (!active_) { throw std::runtime_error("scripted transaction inactive"); } ++parent_.queuedDecrements; } void pruneReference(std::string_view) override {} Result commit() override { ++parent_.commitCalls; if (parent_.failCommit) { return Error{ErrorCode::TransactionFailed, "scripted commit failure"}; } active_ = false; return {}; } void rollback() override { if (!active_) { return; } ++parent_.rollbackCalls; active_ = false; } bool isActive() const override { return active_; } private: ScriptedReferenceCounter& parent_; bool active_{true}; }; }; std::unique_ptr ScriptedReferenceCounter::beginTransaction() { ++beginCalls; return std::make_unique(*this); } fs::path makeTempDir(std::string_view prefix) { auto path = fs::temp_directory_path() / (std::string(prefix) + "_" + std::to_string(std::random_device{}())); fs::create_directories(path); return path; } ContentStoreConfig readinessConfig(const fs::path& path) { ContentStoreConfig config; config.storagePath = path; config.chunkSize = MIN_CHUNK_SIZE; config.enableCompression = false; return config; } // ============================================================================= // Basic Store and Retrieve Tests // ============================================================================= TEST_CASE("ContentStore: Basic store and retrieve", "[api][content-store]") { ContentStoreFixture fixture; SECTION("Store and retrieve simple text file") { std::string content = "Hello, Kronos!"; auto inputPath = fixture.createTestFile("test.txt", content); ContentMetadata metadata; metadata.mimeType = "text/plain"; metadata.name = "test.txt"; auto storeResult = fixture.store_->store(inputPath, metadata); REQUIRE(storeResult.has_value()); CHECK_FALSE(storeResult.value().contentHash.empty()); CHECK(storeResult.value().bytesStored == content.size()); auto outputPath = fixture.testDir_ / "retrieved.txt"; auto retrieveResult = fixture.store_->retrieve(storeResult.value().contentHash, outputPath); REQUIRE(retrieveResult.has_value()); CHECK(retrieveResult.value().found); CHECK(retrieveResult.value().size == content.size()); CHECK(fixture.readFile(outputPath) == content); } SECTION("Store file without metadata") { auto file = fixture.createTestFile("bare.txt", "Bare content"); auto result = fixture.store_->store(file); REQUIRE(result.has_value()); CHECK_FALSE(result.value().contentHash.empty()); } SECTION("Store empty file") { auto file = fixture.createTestFile("empty.txt", ""); auto result = fixture.store_->store(file); REQUIRE(result.has_value()); CHECK(result.value().bytesStored == 0); } SECTION("Store binary content") { std::string binaryContent; for (int i = 0; i < 256; ++i) { binaryContent.push_back(static_cast(i)); } auto file = fixture.createTestFile("binary.bin", binaryContent); ContentMetadata metadata; metadata.mimeType = "application/octet-stream"; metadata.name = "binary.bin"; auto result = fixture.store_->store(file, metadata); REQUIRE(result.has_value()); // Note: bytesStored may include additional metadata or vary by implementation CHECK(result.value().bytesStored >= 256); // Retrieve and verify binary content intact auto outPath = fixture.testDir_ / "binary_out.bin"; auto retrieved = fixture.store_->retrieve(result.value().contentHash, outPath); REQUIRE(retrieved.has_value()); // Binary round-trip may have implementation-specific behavior // Just verify we can retrieve the content CHECK(retrieved.value().found); } } // ============================================================================= // Deduplication Tests // ============================================================================= TEST_CASE("ContentStore: Deduplication", "[api][content-store][dedup]") { ContentStoreFixture fixture; SECTION("Identical content produces same hash") { std::string content = "Duplicate content for testing"; auto file1 = fixture.createTestFile("dup1.txt", content); auto result1 = fixture.store_->store(file1); REQUIRE(result1.has_value()); auto file2 = fixture.createTestFile("dup2.txt", content); auto result2 = fixture.store_->store(file2); REQUIRE(result2.has_value()); CHECK(result1.value().contentHash == result2.value().contentHash); CHECK(result2.value().bytesDeduped > 0); CHECK(result2.value().dedupRatio() == Catch::Approx(1.0)); } SECTION("Different content produces different hash") { auto file1 = fixture.createTestFile("unique1.txt", "Content A"); auto file2 = fixture.createTestFile("unique2.txt", "Content B"); auto result1 = fixture.store_->store(file1); auto result2 = fixture.store_->store(file2); REQUIRE(result1.has_value()); REQUIRE(result2.has_value()); CHECK(result1.value().contentHash != result2.value().contentHash); } SECTION("Case sensitive content") { auto lower = fixture.createTestFile("lower.txt", "hello world"); auto upper = fixture.createTestFile("upper.txt", "HELLO WORLD"); auto r1 = fixture.store_->store(lower); auto r2 = fixture.store_->store(upper); REQUIRE(r1.has_value()); REQUIRE(r2.has_value()); CHECK(r1.value().contentHash != r2.value().contentHash); } SECTION("Whitespace matters") { auto normal = fixture.createTestFile("normal.txt", "hello world"); auto extra = fixture.createTestFile("extra.txt", "hello world"); auto r1 = fixture.store_->store(normal); auto r2 = fixture.store_->store(extra); REQUIRE(r1.has_value()); REQUIRE(r2.has_value()); CHECK(r1.value().contentHash != r2.value().contentHash); } SECTION("Removing one of two shared references keeps blob retrievable") { std::string content = "Shared blob content that two documents reference"; auto file1 = fixture.createTestFile("shared1.txt", content); auto result1 = fixture.store_->store(file1); REQUIRE(result1.has_value()); auto file2 = fixture.createTestFile("shared2.txt", content); auto result2 = fixture.store_->store(file2); REQUIRE(result2.has_value()); const std::string hash = result1.value().contentHash; REQUIRE(hash == result2.value().contentHash); auto existsBefore = fixture.store_->exists(hash); REQUIRE(existsBefore.has_value()); CHECK(existsBefore.value()); // Delete the first logical reference. The blob is still referenced by // the second document, so it MUST remain retrievable. auto removeFirst = fixture.store_->remove(hash); REQUIRE(removeFirst.has_value()); auto existsAfterFirst = fixture.store_->exists(hash); REQUIRE(existsAfterFirst.has_value()); CHECK(existsAfterFirst.value()); auto retrievedAfterFirst = fixture.store_->retrieveBytes(hash); REQUIRE(retrievedAfterFirst.has_value()); std::string roundTripped(reinterpret_cast(retrievedAfterFirst.value().data()), retrievedAfterFirst.value().size()); CHECK(roundTripped == content); // Delete the second (last) reference. The manifest and content metadata are // now removed; physical chunk eviction is deferred to the garbage collector // for blocks that reached a zero reference count. auto removeSecond = fixture.store_->remove(hash); REQUIRE(removeSecond.has_value()); CHECK(removeSecond.value()); // The manifest is gone, so the content is no longer logically present: a // further remove reports "nothing removed". auto removeThird = fixture.store_->remove(hash); REQUIRE(removeThird.has_value()); CHECK_FALSE(removeThird.value()); } } // ============================================================================= // Metadata Operations Tests // ============================================================================= TEST_CASE("ContentStore: Metadata operations", "[api][content-store][metadata]") { ContentStoreFixture fixture; SECTION("Store and retrieve metadata") { auto file = fixture.createTestFile("meta.txt", "Content with metadata"); ContentMetadata metadata; metadata.mimeType = "text/plain"; metadata.name = "meta.txt"; metadata.tags["category"] = "test"; metadata.tags["type"] = "sample"; metadata.tags["author"] = "test_suite"; auto storeResult = fixture.store_->store(file, metadata); REQUIRE(storeResult.has_value()); auto hash = storeResult.value().contentHash; auto metaResult = fixture.store_->getMetadata(hash); REQUIRE(metaResult.has_value()); auto& retrieved = metaResult.value(); CHECK(retrieved.mimeType == metadata.mimeType); CHECK(retrieved.name == metadata.name); CHECK(retrieved.tags == metadata.tags); CHECK(retrieved.tags.at("author") == "test_suite"); } SECTION("Update metadata") { auto file = fixture.createTestFile("update.txt", "Update test"); auto storeResult = fixture.store_->store(file); REQUIRE(storeResult.has_value()); auto hash = storeResult.value().contentHash; ContentMetadata updated; updated.tags["status"] = "updated"; auto updateResult = fixture.store_->updateMetadata(hash, updated); REQUIRE(updateResult.has_value()); auto updatedMeta = fixture.store_->getMetadata(hash); REQUIRE(updatedMeta.has_value()); CHECK(updatedMeta.value().tags.at("status") == "updated"); } SECTION("Multiple tags") { auto file = fixture.createTestFile("tagged.txt", "Tagged content"); ContentMetadata metadata; metadata.tags["tag1"] = "value1"; metadata.tags["tag2"] = "value2"; metadata.tags["tag3"] = "value3"; metadata.tags["tag4"] = "value4"; metadata.tags["tag5"] = "value5"; auto result = fixture.store_->store(file, metadata); REQUIRE(result.has_value()); auto retrieved = fixture.store_->getMetadata(result.value().contentHash); REQUIRE(retrieved.has_value()); CHECK(retrieved.value().tags.size() == 5); } SECTION("Special characters in tags") { auto file = fixture.createTestFile("special.txt", "Special"); ContentMetadata metadata; metadata.tags["path/with/slashes"] = "/some/path"; metadata.tags["unicode_tag"] = "日本語"; metadata.tags["spaces in key"] = "spaces in value"; auto result = fixture.store_->store(file, metadata); REQUIRE(result.has_value()); auto retrieved = fixture.store_->getMetadata(result.value().contentHash); REQUIRE(retrieved.has_value()); CHECK(retrieved.value().tags.at("unicode_tag") == "日本語"); } SECTION("Internal hash hint tags are stripped from persisted file metadata") { const std::string content = "trusted hash hint"; auto file = fixture.createTestFile("trusted.txt", content); ContentMetadata metadata; metadata.name = "trusted.txt"; metadata.tags["visible"] = "keep"; metadata.tags["__yams_trusted_hash_hint"] = "1"; metadata.tags["__yams_hash_hint_mtime_ns"] = "123"; auto result = fixture.store_->store(file, metadata); REQUIRE(result.has_value()); auto retrieved = fixture.store_->getMetadata(result.value().contentHash); REQUIRE(retrieved.has_value()); CHECK(retrieved.value().tags.contains("visible")); CHECK_FALSE(retrieved.value().tags.contains("__yams_trusted_hash_hint")); CHECK_FALSE(retrieved.value().tags.contains("__yams_hash_hint_mtime_ns")); CHECK(retrieved.value().contentHash == result.value().contentHash); CHECK(retrieved.value().size == content.size()); } } // ============================================================================= // Stream Operations Tests // ============================================================================= TEST_CASE("ContentStore: Stream operations", "[api][content-store][stream]") { ContentStoreFixture fixture; SECTION("Store and retrieve via stream") { std::string content = "Stream test content"; std::istringstream input(content); ContentMetadata metadata; metadata.name = "stream.txt"; auto storeResult = fixture.store_->storeStream(input, metadata); REQUIRE(storeResult.has_value()); std::ostringstream output; auto retrieveResult = fixture.store_->retrieveStream(storeResult.value().contentHash, output); REQUIRE(retrieveResult.has_value()); CHECK(output.str() == content); } SECTION("Store large stream") { std::string largeContent(100000, 'X'); std::istringstream input(largeContent); auto result = fixture.store_->storeStream(input); REQUIRE(result.has_value()); CHECK(result.value().bytesStored == 100000); } SECTION("Empty stream") { std::istringstream empty(""); auto result = fixture.store_->storeStream(empty); REQUIRE(result.has_value()); CHECK(result.value().bytesStored == 0); } } // ============================================================================= // Memory Operations Tests // ============================================================================= TEST_CASE("ContentStore: Memory operations", "[api][content-store][memory]") { ContentStoreFixture fixture; SECTION("Store and retrieve bytes") { std::string content = "Memory test"; std::vector data; data.reserve(content.size()); for (char c : content) { data.push_back(static_cast(c)); } ContentMetadata metadata; metadata.name = "memory.bin"; auto storeResult = fixture.store_->storeBytes(data, metadata); REQUIRE(storeResult.has_value()); auto retrieveResult = fixture.store_->retrieveBytes(storeResult.value().contentHash); REQUIRE(retrieveResult.has_value()); std::string retrieved(reinterpret_cast(retrieveResult.value().data()), retrieveResult.value().size()); CHECK(retrieved == content); } SECTION("Store binary bytes with null characters") { std::vector data = {std::byte{0x00}, std::byte{0x01}, std::byte{0x00}, std::byte{0xFF}, std::byte{0x00}, std::byte{0xFE}}; auto result = fixture.store_->storeBytes(data); REQUIRE(result.has_value()); auto retrieved = fixture.store_->retrieveBytes(result.value().contentHash); REQUIRE(retrieved.has_value()); CHECK(retrieved.value().size() == 6); CHECK(retrieved.value()[0] == std::byte{0x00}); CHECK(retrieved.value()[3] == std::byte{0xFF}); } SECTION("Retrieve raw returns stored direct bytes without compression header") { const std::string content = "raw direct bytes"; std::vector data; data.reserve(content.size()); for (char c : content) { data.push_back(static_cast(c)); } auto stored = fixture.store_->storeBytes(data); REQUIRE(stored.has_value()); auto raw = fixture.store_->retrieveRaw(stored.value().contentHash); REQUIRE(raw.has_value()); CHECK(raw.value().data == data); CHECK_FALSE(raw.value().header.has_value()); } SECTION("Retrieve raw async returns stored direct bytes") { const std::string content = "async raw bytes"; std::vector data; data.reserve(content.size()); for (char c : content) { data.push_back(static_cast(c)); } auto stored = fixture.store_->storeBytes(data); REQUIRE(stored.has_value()); auto rawFuture = fixture.store_->retrieveRawAsync(stored.value().contentHash); auto raw = rawFuture.get(); REQUIRE(raw.has_value()); CHECK(raw.value().data == data); CHECK_FALSE(raw.value().header.has_value()); } SECTION("Retrieve bytes prefix returns empty vector when max bytes is zero") { std::vector data = {std::byte{0x41}, std::byte{0x42}, std::byte{0x43}}; auto stored = fixture.store_->storeBytes(data); REQUIRE(stored.has_value()); auto prefix = fixture.store_->retrieveBytesPrefix(stored.value().contentHash, 0); REQUIRE(prefix.has_value()); CHECK(prefix.value().empty()); } } // ============================================================================= // Batch Operations Tests // ============================================================================= TEST_CASE("ContentStore: Batch operations", "[api][content-store][batch]") { ContentStoreFixture fixture; SECTION("Batch store multiple files") { std::vector files; std::vector metadata; for (int i = 0; i < 5; ++i) { std::string name = "batch" + std::to_string(i) + ".txt"; std::string content = "Batch content " + std::to_string(i); files.push_back(fixture.createTestFile(name, content)); ContentMetadata meta; meta.name = name; metadata.push_back(meta); } auto results = fixture.store_->storeBatch(files, metadata); REQUIRE(results.size() == files.size()); std::vector hashes; for (const auto& result : results) { REQUIRE(result.has_value()); hashes.push_back(result.value().contentHash); } // All hashes should be unique (different content) std::sort(hashes.begin(), hashes.end()); auto last = std::unique(hashes.begin(), hashes.end()); CHECK(std::distance(hashes.begin(), last) == static_cast(hashes.size())); } SECTION("Batch remove") { std::vector hashes; for (int i = 0; i < 3; ++i) { auto file = fixture.createTestFile("rm" + std::to_string(i) + ".txt", "Remove " + std::to_string(i)); auto result = fixture.store_->store(file); REQUIRE(result.has_value()); hashes.push_back(result.value().contentHash); } auto removeResults = fixture.store_->removeBatch(hashes); REQUIRE(removeResults.size() == hashes.size()); for (const auto& result : removeResults) { REQUIRE(result.has_value()); CHECK(result.value()); } } } TEST_CASE("ContentStore: Batch store commits references once before publishing metadata", "[api][content-store][batch][publication]") { auto tempDir = makeTempDir("content_store_batch_publication"); auto storage = std::make_shared(); auto refCounter = std::make_shared(); ContentStoreBuilder builder; auto built = builder.withConfig(readinessConfig(tempDir)) .withStorageEngine(storage) .withReferenceCounter(refCounter) .build(); REQUIRE(built.has_value()); auto store = std::move(built).value(); std::vector files; for (int i = 0; i < 2; ++i) { auto path = tempDir / ("batch-" + std::to_string(i) + ".txt"); std::ofstream file(path, std::ios::binary); file << "batch publication " << i; file.close(); files.push_back(std::move(path)); } auto results = store->storeBatch(files); REQUIRE(results.size() == files.size()); REQUIRE(results[0].has_value()); REQUIRE(results[1].has_value()); CHECK(storage->storeBatchCalls == 2); CHECK(refCounter->beginCalls == 1); CHECK(refCounter->commitCalls == 1); CHECK(refCounter->queuedIncrements == 2); for (const auto& result : results) { auto metadata = store->getMetadata(result.value().contentHash); REQUIRE(metadata.has_value()); } std::error_code ec; fs::remove_all(tempDir, ec); } TEST_CASE("ContentStore: Remove deletes directly stored small content", "[api][content-store][remove][direct]") { ContentStoreFixture fixture; const std::string payload = "small direct payload"; const auto data = std::span(reinterpret_cast(payload.data()), payload.size()); const auto stored = fixture.store_->storeBytes(data); REQUIRE(stored.has_value()); REQUIRE(fixture.store_->exists(stored.value().contentHash).value()); const auto removed = fixture.store_->remove(stored.value().contentHash); REQUIRE(removed.has_value()); CHECK(removed.value()); CHECK_FALSE(fixture.store_->exists(stored.value().contentHash).value()); } TEST_CASE("ContentStore: Remove commits reference decrements through its transaction", "[api][content-store][remove][transaction]") { auto tempDir = makeTempDir("content_store_remove_transaction"); auto storage = std::make_shared(); auto refCounter = std::make_shared(); ContentStoreBuilder builder; auto built = builder.withConfig(readinessConfig(tempDir)) .withStorageEngine(storage) .withReferenceCounter(refCounter) .build(); REQUIRE(built.has_value()); auto store = std::move(built).value(); const auto path = tempDir / "remove-transaction.txt"; std::ofstream(path, std::ios::binary) << "transactional decrement"; auto stored = store->store(path); REQUIRE(stored.has_value()); refCounter->beginCalls = 0; refCounter->commitCalls = 0; refCounter->rollbackCalls = 0; refCounter->queuedDecrements = 0; refCounter->immediateDecrements = 0; auto removed = store->remove(stored.value().contentHash); REQUIRE(removed.has_value()); CHECK(removed.value()); CHECK(refCounter->beginCalls == 1); CHECK(refCounter->commitCalls == 1); CHECK(refCounter->rollbackCalls == 0); CHECK(refCounter->immediateDecrements == 0); CHECK(refCounter->queuedDecrements > 0); std::error_code ec; fs::remove_all(tempDir, ec); } TEST_CASE("ContentStore: Remove commit failure rolls back and keeps manifest", "[api][content-store][remove][transaction][rollback]") { auto tempDir = makeTempDir("content_store_remove_commit_failure"); auto storage = std::make_shared(); auto refCounter = std::make_shared(); ContentStoreBuilder builder; auto built = builder.withConfig(readinessConfig(tempDir)) .withStorageEngine(storage) .withReferenceCounter(refCounter) .build(); REQUIRE(built.has_value()); auto store = std::move(built).value(); const auto path = tempDir / "remove-failure.txt"; std::ofstream(path, std::ios::binary) << "rollback decrement"; auto stored = store->store(path); REQUIRE(stored.has_value()); const auto manifestKey = stored.value().contentHash + ".manifest"; refCounter->failCommit = true; refCounter->beginCalls = 0; refCounter->commitCalls = 0; refCounter->rollbackCalls = 0; refCounter->queuedDecrements = 0; refCounter->immediateDecrements = 0; auto removed = store->remove(stored.value().contentHash); REQUIRE_FALSE(removed.has_value()); CHECK(removed.error().code == ErrorCode::TransactionFailed); CHECK(refCounter->beginCalls == 1); CHECK(refCounter->commitCalls == 1); CHECK(refCounter->rollbackCalls == 1); CHECK(refCounter->immediateDecrements == 0); CHECK(refCounter->queuedDecrements > 0); auto manifestExists = storage->exists(manifestKey); REQUIRE(manifestExists.has_value()); CHECK(manifestExists.value()); std::error_code ec; fs::remove_all(tempDir, ec); } TEST_CASE("ContentStore: Batch store preserves last-writer manifest metadata for duplicate content", "[api][content-store][batch][manifest]") { auto tempDir = makeTempDir("content_store_batch_duplicate_manifest"); auto storage = std::make_shared(); auto refCounter = std::make_shared(); ContentStoreBuilder builder; auto built = builder.withConfig(readinessConfig(tempDir)) .withStorageEngine(storage) .withReferenceCounter(refCounter) .build(); REQUIRE(built.has_value()); auto store = std::move(built).value(); const std::string content = "shared content with distinct manifest metadata"; std::vector files; for (int i = 0; i < 2; ++i) { auto path = tempDir / ("duplicate-" + std::to_string(i) + ".txt"); std::ofstream(path, std::ios::binary) << content; files.push_back(std::move(path)); } std::vector metadata(2); metadata[0].name = "first-name.txt"; metadata[1].name = "last-name.txt"; auto results = store->storeBatch(files, metadata); REQUIRE(results.size() == files.size()); REQUIRE(results[0].has_value()); REQUIRE(results[1].has_value()); REQUIRE(results[0].value().contentHash == results[1].value().contentHash); auto manifestBytes = storage->retrieve(results[0].value().contentHash + ".manifest"); REQUIRE(manifestBytes.has_value()); yams::manifest::ManifestManager manifestManager({}); auto manifest = manifestManager.deserialize(manifestBytes.value()); REQUIRE(manifest.has_value()); CHECK(manifest.value().originalName == "last-name.txt"); std::error_code ec; fs::remove_all(tempDir, ec); } TEST_CASE("ContentStore: Batch reference failure publishes no metadata", "[api][content-store][batch][publication][rollback]") { auto tempDir = makeTempDir("content_store_batch_commit_failure"); auto storage = std::make_shared(); auto refCounter = std::make_shared(); refCounter->failCommit = true; ContentStoreBuilder builder; auto built = builder.withConfig(readinessConfig(tempDir)) .withStorageEngine(storage) .withReferenceCounter(refCounter) .build(); REQUIRE(built.has_value()); auto store = std::move(built).value(); std::vector files; std::vector hashes; for (int i = 0; i < 2; ++i) { auto path = tempDir / ("batch-fail-" + std::to_string(i) + ".txt"); std::ofstream file(path, std::ios::binary); file << "batch failure " << i; file.close(); hashes.push_back(yams::crypto::SHA256Hasher{}.hashFile(path)); files.push_back(std::move(path)); } auto results = store->storeBatch(files); REQUIRE(results.size() == files.size()); for (const auto& result : results) { REQUIRE_FALSE(result.has_value()); CHECK(result.error().code == ErrorCode::TransactionFailed); } CHECK(refCounter->beginCalls == 1); CHECK(refCounter->commitCalls == 1); CHECK(refCounter->rollbackCalls == 1); for (const auto& hash : hashes) { auto metadata = store->getMetadata(hash); REQUIRE_FALSE(metadata.has_value()); auto manifestExists = storage->exists(hash + ".manifest"); REQUIRE(manifestExists.has_value()); CHECK_FALSE(manifestExists.value()); } std::error_code ec; fs::remove_all(tempDir, ec); } // ============================================================================= // Progress Reporting Tests // ============================================================================= TEST_CASE("ContentStore: Progress reporting", "[api][content-store][progress]") { ContentStoreFixture fixture; SECTION("Progress callback invoked") { std::string content(std::size_t{1024} * 1024, 'X'); // 1MB auto file = fixture.createTestFile("large.bin", content); std::atomic progressCalls{0}; std::atomic lastBytes{0}; ProgressCallback progressCallback = [&](const Progress& progress) { progressCalls++; lastBytes = progress.bytesProcessed; REQUIRE(progress.bytesProcessed <= progress.totalBytes); }; ContentMetadata metadata; metadata.name = "large.bin"; auto result = fixture.store_->store(file, metadata, progressCallback); REQUIRE(result.has_value()); CHECK(progressCalls.load() > 0); CHECK(lastBytes.load() == content.size()); } SECTION("Progress percentage calculation") { std::string content(10000, 'Y'); auto file = fixture.createTestFile("progress.bin", content); std::vector percentages; ProgressCallback callback = [&](const Progress& p) { percentages.push_back(p.percentage); }; fixture.store_->store(file, ContentMetadata{}, callback); CHECK_FALSE(percentages.empty()); // Last percentage should be 100% CHECK(percentages.back() == Catch::Approx(100.0)); } } // ============================================================================= // Error Handling Tests // ============================================================================= TEST_CASE("ContentStore: Error handling", "[api][content-store][error]") { ContentStoreFixture fixture; SECTION("Store non-existent file") { auto result = fixture.store_->store(fixture.testDir_ / "nonexistent.txt"); CHECK_FALSE(result.has_value()); CHECK(result.error() == ErrorCode::FileNotFound); } SECTION("Retrieve with invalid hash") { auto result = fixture.store_->retrieve("invalid_hash", fixture.testDir_ / "out.txt"); CHECK_FALSE(result.has_value()); } SECTION("Remove non-existent content returns false not error") { auto result = fixture.store_->remove("nonexistent_hash"); REQUIRE(result.has_value()); CHECK_FALSE(result.value()); } SECTION("Store to read-only directory") { // This test is platform-specific and may not work on all systems // Just verify we handle errors gracefully } } TEST_CASE("ContentStore: Storage readiness failures", "[api][content-store][readiness]") { SECTION("Injected not-ready storage is used and not replaced by local fallback") { auto tempDir = makeTempDir("content_store_injected_not_ready"); auto storage = std::make_shared(); storage->failStoresAsNotInitialized = true; { ContentStoreBuilder builder; auto built = builder.withConfig(readinessConfig(tempDir)).withStorageEngine(storage).build(); REQUIRE(built.has_value()); auto store = std::move(built).value(); std::vector data(128, std::byte{0x61}); auto stored = store->storeBytes(data); REQUIRE_FALSE(stored.has_value()); CHECK(stored.error().code == ErrorCode::NotInitialized); CHECK(storage->storeCalls == 1); CHECK(storage->empty()); } std::error_code ec; fs::remove_all(tempDir, ec); } SECTION("Explicit null storage injection is rejected") { auto tempDir = makeTempDir("content_store_null_storage"); { ContentStoreBuilder builder; auto built = builder.withConfig(readinessConfig(tempDir)) .withStorageEngine(std::shared_ptr{}) .build(); REQUIRE_FALSE(built.has_value()); CHECK(built.error().code == ErrorCode::InvalidArgument); } std::error_code ec; fs::remove_all(tempDir, ec); } } TEST_CASE("ContentStore: Chunked add rollback leaves no committed manifest or metadata", "[api][content-store][readiness][rollback]") { auto tempDir = makeTempDir("content_store_partial_rollback"); auto storage = std::make_shared(); storage->failManifestStore = true; { ContentStoreBuilder builder; auto built = builder.withConfig(readinessConfig(tempDir)).withStorageEngine(storage).build(); REQUIRE(built.has_value()); auto store = std::move(built).value(); std::vector data(MIN_CHUNK_SIZE + 1024); for (size_t i = 0; i < data.size(); ++i) { data[i] = static_cast(i & 0xff); } const auto hash = yams::crypto::SHA256Hasher::hash(std::span(data.data(), data.size())); auto stored = store->storeBytes(data); REQUIRE_FALSE(stored.has_value()); CHECK(stored.error().code == ErrorCode::WriteError); CHECK(storage->storeCalls > 0); auto manifestExists = storage->exists(hash + ".manifest"); REQUIRE(manifestExists.has_value()); CHECK_FALSE(manifestExists.value()); auto metadata = store->getMetadata(hash); REQUIRE_FALSE(metadata.has_value()); CHECK(metadata.error().code == ErrorCode::FileNotFound); auto stats = store->getStats(); CHECK(stats.storeOperations == 0); CHECK(stats.uniqueBlocks == 0); CHECK(stats.totalUncompressedBytes == 0); } std::error_code ec; fs::remove_all(tempDir, ec); } TEST_CASE("ContentStore: File chunker failures return typed errors without durable writes", "[api][content-store][readiness][rollback]") { auto tempDir = makeTempDir("content_store_file_chunk_failure"); auto storage = std::make_shared(); auto chunker = std::make_shared(); { ContentStoreBuilder builder; auto built = builder.withConfig(readinessConfig(tempDir)) .withStorageEngine(storage) .withChunker(chunker) .build(); REQUIRE(built.has_value()); auto store = std::move(built).value(); auto path = tempDir / "input.bin"; std::ofstream file(path, std::ios::binary); file << "chunker failure input"; file.close(); auto stored = store->store(path); REQUIRE_FALSE(stored.has_value()); CHECK(stored.error().code == ErrorCode::InternalError); CHECK(chunker->chunkFileCalls == 1); CHECK(storage->storeCalls == 0); CHECK(storage->empty()); auto stats = store->getStats(); CHECK(stats.storeOperations == 0); CHECK(stats.totalObjects == 0); CHECK(stats.uniqueBlocks == 0); } std::error_code ec; fs::remove_all(tempDir, ec); } TEST_CASE("ContentStore: File add commit failure leaves no committed manifest metadata", "[api][content-store][readiness][rollback]") { auto tempDir = makeTempDir("content_store_file_commit_failure"); auto storage = std::make_shared(); auto refCounter = std::make_shared(); refCounter->failCommit = true; { ContentStoreBuilder builder; auto built = builder.withConfig(readinessConfig(tempDir)) .withStorageEngine(storage) .withReferenceCounter(refCounter) .build(); REQUIRE(built.has_value()); auto store = std::move(built).value(); std::vector data(MIN_CHUNK_SIZE + 2048); for (size_t i = 0; i < data.size(); ++i) { data[i] = static_cast((i * 17) & 0xff); } const auto hash = yams::crypto::SHA256Hasher::hash(std::span(data.data(), data.size())); auto path = tempDir / "commit-fail.bin"; std::ofstream file(path, std::ios::binary); file.write(reinterpret_cast(data.data()), static_cast(data.size())); file.close(); auto stored = store->store(path); REQUIRE_FALSE(stored.has_value()); CHECK(stored.error().code == ErrorCode::TransactionFailed); CHECK(refCounter->beginCalls == 1); CHECK(refCounter->commitCalls == 1); CHECK(refCounter->rollbackCalls == 1); CHECK(refCounter->queuedIncrements > 0); CHECK(storage->removeCalls > 0); CHECK(std::any_of(storage->removedKeys.begin(), storage->removedKeys.end(), [](const std::string& key) { return key.ends_with(".manifest"); })); auto manifestExists = storage->exists(hash + ".manifest"); REQUIRE(manifestExists.has_value()); CHECK_FALSE(manifestExists.value()); auto metadata = store->getMetadata(hash); REQUIRE_FALSE(metadata.has_value()); CHECK(metadata.error().code == ErrorCode::FileNotFound); auto stats = store->getStats(); CHECK(stats.storeOperations == 0); CHECK(stats.uniqueBlocks == 0); CHECK(stats.totalUncompressedBytes == 0); } std::error_code ec; fs::remove_all(tempDir, ec); } TEST_CASE("ContentStore: Ambiguous manifest store is reconciled without rollback", "[api][content-store][readiness][reconcile]") { auto tempDir = makeTempDir("content_store_ambiguous_manifest"); auto storage = std::make_shared(); storage->failManifestStore = true; storage->failManifestStoreAmbiguously = true; { ContentStoreBuilder builder; auto built = builder.withConfig(readinessConfig(tempDir)).withStorageEngine(storage).build(); REQUIRE(built.has_value()); auto store = std::move(built).value(); std::vector data(MIN_CHUNK_SIZE + 2048); for (size_t i = 0; i < data.size(); ++i) { data[i] = static_cast((i * 13) & 0xff); } const auto hash = yams::crypto::SHA256Hasher::hash(std::span(data.data(), data.size())); auto stored = store->storeBytes(data, ContentMetadata{}); REQUIRE(stored.has_value()); CHECK(stored.value().contentHash == hash); CHECK(stored.value().bytesStored > 0); CHECK(storage->storeCalls > 0); CHECK_FALSE(storage->empty()); auto metadata = store->getMetadata(hash); REQUIRE(metadata.has_value()); auto stats = store->getStats(); CHECK(stats.storeOperations == 1); CHECK(stats.totalObjects > 0); CHECK(stats.uniqueBlocks > 0); } std::error_code ec; fs::remove_all(tempDir, ec); } TEST_CASE("ContentStore: Ambiguous manifest store with mismatched bytes rolls back", "[api][content-store][readiness][reconcile]") { auto tempDir = makeTempDir("content_store_ambiguous_mismatch"); auto storage = std::make_shared(); storage->failManifestStore = true; storage->failManifestStoreWithMismatchedDurableBytes = true; { ContentStoreBuilder builder; auto built = builder.withConfig(readinessConfig(tempDir)).withStorageEngine(storage).build(); REQUIRE(built.has_value()); auto store = std::move(built).value(); std::vector data(MIN_CHUNK_SIZE + 2048); for (size_t i = 0; i < data.size(); ++i) { data[i] = static_cast((i * 17) & 0xff); } const auto hash = yams::crypto::SHA256Hasher::hash(std::span(data.data(), data.size())); auto stored = store->storeBytes(data, ContentMetadata{}); REQUIRE_FALSE(stored.has_value()); CHECK(stored.error().code == ErrorCode::NetworkError); CHECK(storage->removeCalls > 0); auto manifestExists = storage->exists(hash + ".manifest"); REQUIRE(manifestExists.has_value()); CHECK_FALSE(manifestExists.value()); auto metadata = store->getMetadata(hash); REQUIRE_FALSE(metadata.has_value()); auto stats = store->getStats(); CHECK(stats.storeOperations == 0); } std::error_code ec; fs::remove_all(tempDir, ec); } // ============================================================================= // Exists Operation Tests // ============================================================================= TEST_CASE("ContentStore: Exists operation", "[api][content-store][exists]") { ContentStoreFixture fixture; SECTION("Exists returns true for stored content") { auto file = fixture.createTestFile("exists.txt", "Test content"); auto storeResult = fixture.store_->store(file); REQUIRE(storeResult.has_value()); auto hash = storeResult.value().contentHash; auto exists = fixture.store_->exists(hash); REQUIRE(exists.has_value()); CHECK(exists.value()); } SECTION("Exists returns false for non-existent content") { auto exists = fixture.store_->exists("nonexistent_hash"); REQUIRE(exists.has_value()); CHECK_FALSE(exists.value()); } SECTION("Exists after remove") { auto file = fixture.createTestFile("temp.txt", "Temporary"); auto storeResult = fixture.store_->store(file); REQUIRE(storeResult.has_value()); auto hash = storeResult.value().contentHash; auto removeResult = fixture.store_->remove(hash); REQUIRE(removeResult.has_value()); // Note: Due to content-addressable storage with reference counting, // content may still exist after remove if referenced elsewhere. // This test verifies remove operation completes successfully. auto exists = fixture.store_->exists(hash); REQUIRE(exists.has_value()); // Content may or may not exist depending on reference count } } // ============================================================================= // Health Check Tests // ============================================================================= TEST_CASE("ContentStore: Health check", "[api][content-store][health]") { ContentStoreFixture fixture; SECTION("Healthy store") { auto health = fixture.store_->checkHealth(); CHECK(health.isHealthy); CHECK_FALSE(health.status.empty()); CHECK(health.errors.empty()); } } TEST_CASE("ContentStore: Verify detects storage integrity issues", "[api][content-store][verify][integrity]") { ContentStoreFixture fixture; SECTION("Verify succeeds for direct and chunked content") { std::vector small(1024, std::byte{0x21}); auto smallStore = fixture.store_->storeBytes(small); REQUIRE(smallStore.has_value()); std::vector large(DEFAULT_CHUNK_SIZE + 32768, std::byte{0x42}); auto largeStore = fixture.store_->storeBytes(large); REQUIRE(largeStore.has_value()); auto verify = fixture.store_->verify(); REQUIRE(verify.has_value()); } SECTION("Verify detects direct object corruption") { std::vector data(1024, std::byte{0x33}); auto stored = fixture.store_->storeBytes(data); REQUIRE(stored.has_value()); const auto& hash = stored.value().contentHash; auto objectPath = fixture.testDir_ / "objects" / hash.substr(0, 2) / hash.substr(2); { std::fstream file(objectPath, std::ios::binary | std::ios::in | std::ios::out); REQUIRE(static_cast(file)); file.seekp(0); const char corrupt = static_cast(0xff); file.write(&corrupt, 1); REQUIRE(static_cast(file)); } auto verify = fixture.store_->verify(); REQUIRE_FALSE(verify.has_value()); CHECK(verify.error() == ErrorCode::CorruptedData); } SECTION("Verify detects missing chunk from manifest") { std::vector large(DEFAULT_CHUNK_SIZE + 32768, std::byte{0x44}); for (size_t i = 0; i < large.size(); ++i) { large[i] = static_cast(i & 0xff); } auto stored = fixture.store_->storeBytes(large); REQUIRE(stored.has_value()); bool removedObject = false; const auto objectsRoot = fixture.testDir_ / "objects"; for (const auto& entry : fs::recursive_directory_iterator(objectsRoot)) { if (entry.is_regular_file()) { fs::remove(entry.path()); removedObject = true; break; } } REQUIRE(removedObject); auto verify = fixture.store_->verify(); REQUIRE_FALSE(verify.has_value()); CHECK(verify.error() == ErrorCode::CorruptedData); } } // ============================================================================= // Statistics Tests // ============================================================================= TEST_CASE("ContentStore: Statistics", "[api][content-store][stats]") { ContentStoreFixture fixture; SECTION("Statistics update after operations") { auto initialStats = fixture.store_->getStats(); for (int i = 0; i < 3; ++i) { auto file = fixture.createTestFile("stats" + std::to_string(i) + ".txt", "Content " + std::to_string(i)); fixture.store_->store(file); } auto finalStats = fixture.store_->getStats(); CHECK(finalStats.totalObjects > initialStats.totalObjects); CHECK(finalStats.totalBytes > initialStats.totalBytes); CHECK(finalStats.storeOperations > initialStats.storeOperations); } } // ============================================================================= // Progress Reporter Unit Tests // ============================================================================= TEST_CASE("ProgressReporter: Basic functionality", "[api][progress]") { SECTION("Basic progress") { ProgressReporter reporter(1000); std::vector updates; reporter.setCallback([&updates](const Progress& p) { updates.push_back(p); }); reporter.reportProgress(250); reporter.reportProgress(500); reporter.reportProgress(750); reporter.reportProgress(1000); REQUIRE(updates.size() >= 4); CHECK(updates.back().bytesProcessed == 1000); CHECK(updates.back().totalBytes == 1000); CHECK(updates.back().percentage == Catch::Approx(100.0)); } SECTION("Cancellation") { ProgressReporter reporter; CHECK_FALSE(reporter.isCancelled()); reporter.cancel(); CHECK(reporter.isCancelled()); CHECK_THROWS_AS(reporter.throwIfCancelled(), OperationCancelledException); } SECTION("Sub-reporter") { ProgressReporter parent(1000); auto sub = parent.createSubReporter(500); sub->reportProgress(250); CHECK(parent.getBytesProcessed() == 125); // 250/500 * 500/2 = 125 } } // ============================================================================= // Content Metadata Unit Tests // ============================================================================= TEST_CASE("ContentMetadata: Serialization", "[api][metadata]") { SECTION("JSON round-trip") { ContentMetadata original; original.mimeType = "application/json"; original.name = "data.json"; original.tags["category"] = "test"; original.tags["format"] = "json"; original.tags["version"] = "1.0"; auto json = original.toJson(); REQUIRE_FALSE(json.empty()); auto result = ContentMetadata::fromJson(json); REQUIRE(result.has_value()); auto& deserialized = result.value(); CHECK(deserialized.mimeType == original.mimeType); CHECK(deserialized.name == original.name); CHECK(deserialized.tags == original.tags); CHECK(deserialized.tags.at("version") == "1.0"); } SECTION("Empty metadata serialization") { ContentMetadata empty; auto json = empty.toJson(); auto result = ContentMetadata::fromJson(json); REQUIRE(result.has_value()); } } TEST_CASE("ContentMetadata: Tag operations", "[api][metadata][tags]") { ContentMetadata metadata; SECTION("Add and check tag") { CHECK_FALSE(metadata.tags.count("test")); metadata.tags["test"] = "true"; CHECK(metadata.tags.count("test")); } SECTION("Duplicate tag key overwrites") { metadata.tags["key"] = "value1"; metadata.tags["key"] = "value2"; CHECK(metadata.tags.size() == 1); CHECK(metadata.tags["key"] == "value2"); } SECTION("Remove tag") { metadata.tags["test"] = "value"; metadata.tags.erase("test"); CHECK_FALSE(metadata.tags.count("test")); } } TEST_CASE("MetadataQuery: Matching", "[api][metadata][query]") { ContentMetadata metadata; metadata.mimeType = "text/plain"; metadata.name = "test.txt"; metadata.tags["priority"] = "important"; metadata.tags["type"] = "document"; metadata.tags["author"] = "john"; SECTION("Match by MIME type") { MetadataQuery query; query.mimeType = "text/plain"; CHECK(query.matches(metadata)); } SECTION("No match for wrong MIME type") { MetadataQuery query; query.mimeType = "image/png"; CHECK_FALSE(query.matches(metadata)); } SECTION("Match by required tags") { MetadataQuery query; query.requiredTags = {"priority"}; CHECK(query.matches(metadata)); } SECTION("Match by any tags") { MetadataQuery query; query.anyTags = {"priority", "type"}; CHECK(query.matches(metadata)); } SECTION("Exclude tags") { MetadataQuery query; query.excludeTags = {"priority"}; CHECK_FALSE(query.matches(metadata)); } SECTION("Exclude non-existent tag passes") { MetadataQuery query; query.excludeTags = {"nonexistent"}; CHECK(query.matches(metadata)); } } // ============================================================================= // Builder Tests // ============================================================================= TEST_CASE("ContentStoreBuilder: Construction", "[api][builder]") { SECTION("Default build") { auto tempDir = fs::temp_directory_path() / ("builder_test_" + std::to_string(std::random_device{}())); fs::create_directories(tempDir); { auto result = ContentStoreBuilder::createDefault(tempDir); REQUIRE(result.has_value()); auto store = std::move(result).value(); CHECK(store != nullptr); } std::error_code ec; fs::remove_all(tempDir, ec); } SECTION("Custom config") { ContentStoreConfig config; config.storagePath = fs::temp_directory_path() / ("custom_test_" + std::to_string(std::random_device{}())); config.chunkSize = std::size_t{128} * 1024; config.enableCompression = true; config.compressionType = "zstd"; config.compressionLevel = 5; { ContentStoreBuilder builder; auto result = builder.withConfig(config).build(); REQUIRE(result.has_value()); } std::error_code ec; fs::remove_all(config.storagePath, ec); } SECTION("Validation error for empty storage path") { ContentStoreConfig config; // Invalid: empty storage path auto result = ContentStoreBuilder::createFromConfig(config); CHECK_FALSE(result.has_value()); CHECK(result.error() == ErrorCode::InvalidArgument); } } // ============================================================================= // Edge Cases and Stress Tests // ============================================================================= TEST_CASE("ContentStore: Edge cases", "[api][content-store][edge]") { ContentStoreFixture fixture; #ifndef _WIN32 // Skip on Windows due to MAX_PATH limitations SECTION("Very long filename") { std::string longName(200, 'x'); longName += ".txt"; auto file = fixture.createTestFile(longName, "Long filename content"); ContentMetadata meta; meta.name = longName; auto result = fixture.store_->store(file, meta); REQUIRE(result.has_value()); } #endif SECTION("Unicode filename") { auto file = fixture.createTestFile("日本語ファイル.txt", "Unicode content"); auto result = fixture.store_->store(file); REQUIRE(result.has_value()); } SECTION("Concurrent reads of same content") { auto file = fixture.createTestFile("concurrent.txt", "Concurrent content"); auto storeResult = fixture.store_->store(file); REQUIRE(storeResult.has_value()); auto hash = storeResult.value().contentHash; std::vector threads; std::atomic successCount{0}; for (int i = 0; i < 5; ++i) { threads.emplace_back([&, i]() { auto outPath = fixture.testDir_ / ("out_" + std::to_string(i) + ".txt"); auto result = fixture.store_->retrieve(hash, outPath); if (result.has_value() && result.value().found) { successCount++; } }); } for (auto& t : threads) { t.join(); } CHECK(successCount.load() == 5); } SECTION("Concurrent stores of same file") { auto file = fixture.createTestFile("concurrent_store.txt", "Concurrent store content"); constexpr int kThreadCount = 6; constexpr int kStoresPerThread = 4; std::vector threads; std::atomic successCount{0}; std::mutex resultMutex; std::vector hashes; std::vector errors; hashes.reserve(static_cast(kThreadCount) * static_cast(kStoresPerThread)); for (int i = 0; i < kThreadCount; ++i) { threads.emplace_back([&, i]() { ContentMetadata metadata; metadata.name = "concurrent_store_" + std::to_string(i) + ".txt"; metadata.mimeType = "text/plain"; for (int j = 0; j < kStoresPerThread; ++j) { auto result = fixture.store_->store(file, metadata); if (result.has_value()) { successCount.fetch_add(1, std::memory_order_relaxed); std::lock_guard lock(resultMutex); hashes.push_back(result.value().contentHash); } else { std::lock_guard lock(resultMutex); errors.push_back("store failed in thread " + std::to_string(i) + " iter " + std::to_string(j)); } } }); } for (auto& t : threads) { t.join(); } INFO("errors=" << errors.size()); CHECK(successCount.load() == kThreadCount * kStoresPerThread); REQUIRE_FALSE(hashes.empty()); for (const auto& hash : hashes) { CHECK(hash == hashes.front()); } } } TEST_CASE("ContentStore: Health check reports status", "[api][content-store][health]") { ContentStoreFixture fixture; auto health = fixture.store_->checkHealth(); CHECK(health.isHealthy); CHECK(health.status.find("Healthy") != std::string::npos); } TEST_CASE("ContentStore: Compact operation succeeds", "[api][content-store][compact]") { ContentStoreFixture fixture; auto file = fixture.createTestFile("compact_test.txt", "compact me"); auto stored = fixture.store_->store(file); REQUIRE(stored.has_value()); auto result = fixture.store_->compact(nullptr); CHECK(result.has_value()); } TEST_CASE("ContentStore: GarbageCollect operates on stored content", "[api][content-store][gc]") { ContentStoreFixture fixture; auto file = fixture.createTestFile("gc_test.txt", "content for garbage collection"); ContentMetadata metadata; metadata.name = "gc_test.txt"; auto stored = fixture.store_->store(file, metadata); REQUIRE(stored.has_value()); auto hash = stored.value().contentHash; REQUIRE(fixture.store_->remove(hash).has_value()); auto result = fixture.store_->garbageCollect(nullptr); CHECK(result.has_value()); } TEST_CASE("ContentStore: Verify reports success on clean store", "[api][content-store][verify]") { ContentStoreFixture fixture; auto file = fixture.createTestFile("verify_test.txt", "verify me"); auto stored = fixture.store_->store(file); REQUIRE(stored.has_value()); auto result = fixture.store_->verify(nullptr); CHECK(result.has_value()); } TEST_CASE("ContentStore: Retrieve bytes with non-existent hash returns error", "[api][content-store][memory]") { ContentStoreFixture fixture; std::string nonExistentHash(64, '0'); auto result = fixture.store_->retrieveBytes(nonExistentHash); CHECK_FALSE(result.has_value()); } TEST_CASE("ContentStore: Retrieve bytes prefix returns subset", "[api][content-store][memory]") { ContentStoreFixture fixture; std::string content = "Hello, prefix test content!"; std::vector data; data.reserve(content.size()); for (char c : content) { data.push_back(static_cast(c)); } ContentMetadata metadata; metadata.name = "prefix.bin"; auto storeResult = fixture.store_->storeBytes(data, metadata); REQUIRE(storeResult.has_value()); auto partial = fixture.store_->retrieveBytesPrefix(storeResult.value().contentHash, 5); REQUIRE(partial.has_value()); CHECK(partial.value().size() == 5); } TEST_CASE("ContentStore: Retrieve stream with progress callback", "[api][content-store][stream]") { ContentStoreFixture fixture; std::string content = "stream with progress"; std::istringstream input(content); ContentMetadata metadata; metadata.name = "progress_stream.txt"; auto storeResult = fixture.store_->storeStream(input, metadata); REQUIRE(storeResult.has_value()); std::atomic progressCalls{0}; ProgressCallback progressCallback = [&](const Progress& p) { progressCalls++; (void)p; }; auto outPath = fixture.testDir_ / "progress_out.txt"; auto retrieveResult = fixture.store_->retrieve(storeResult.value().contentHash, outPath, progressCallback); REQUIRE(retrieveResult.has_value()); CHECK(retrieveResult.value().found); } // NOLINTEND(bugprone-chained-comparison) } // namespace yams::api::test