Deduplicate unary results, verify direct blobs before writing, and surface stream finalization failures instead of reporting partial output as successful. Signed-off-by: trvon <git@trevon.dev>
1947 lines
70 KiB
C++
1947 lines
70 KiB
C++
// Catch2 migration of content_store_test.cpp with expanded test coverage
|
|
// Epic: yams-3s4 | Migration: api tests
|
|
|
|
#include <catch2/catch_approx.hpp>
|
|
#include <catch2/catch_test_macros.hpp>
|
|
|
|
#include <yams/api/async_content_store.h>
|
|
#include <yams/api/content_metadata.h>
|
|
#include <yams/api/content_store.h>
|
|
#include <yams/api/content_store_builder.h>
|
|
#include <yams/api/content_store_error.h>
|
|
#include <yams/api/progress_reporter.h>
|
|
#include <yams/crypto/hasher.h>
|
|
#include <yams/manifest/manifest_manager.h>
|
|
#include <yams/storage/storage_engine.h>
|
|
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <future>
|
|
#include <mutex>
|
|
#include <random>
|
|
#include <sstream>
|
|
#include <thread>
|
|
#include <unordered_map>
|
|
|
|
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<IContentStore> 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<char>(file), std::istreambuf_iterator<char>());
|
|
}
|
|
};
|
|
|
|
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<std::string> removedKeys;
|
|
|
|
Result<void> store(std::string_view hash, std::span<const std::byte> 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<std::byte>(data.begin(), data.end());
|
|
return Error{ErrorCode::NetworkError, "scripted ambiguous manifest write"};
|
|
}
|
|
if (failManifestStoreWithMismatchedDurableBytes) {
|
|
auto durableBytes = std::vector<std::byte>(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<std::byte>(data.begin(), data.end());
|
|
return {};
|
|
}
|
|
|
|
Result<std::vector<std::byte>> 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<RawObject> 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<bool> exists(std::string_view hash) const noexcept override {
|
|
return objects_.contains(std::string(hash));
|
|
}
|
|
|
|
Result<void> remove(std::string_view hash) override {
|
|
++removeCalls;
|
|
removedKeys.emplace_back(hash.data(), hash.size());
|
|
objects_.erase(std::string(hash));
|
|
return {};
|
|
}
|
|
|
|
Result<uint64_t> 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<uint64_t>(it->second.size());
|
|
}
|
|
|
|
std::future<Result<void>> storeAsync(std::string_view hash,
|
|
std::span<const std::byte> data) override {
|
|
return std::async(std::launch::deferred,
|
|
[this, key = std::string(hash),
|
|
copy = std::vector<std::byte>(data.begin(), data.end())]() {
|
|
return store(key, copy);
|
|
});
|
|
}
|
|
|
|
std::future<Result<std::vector<std::byte>>>
|
|
retrieveAsync(std::string_view hash) const override {
|
|
return std::async(std::launch::deferred,
|
|
[this, key = std::string(hash)]() { return retrieve(key); });
|
|
}
|
|
|
|
std::future<Result<RawObject>> retrieveRawAsync(std::string_view hash) const override {
|
|
return std::async(std::launch::deferred,
|
|
[this, key = std::string(hash)]() { return retrieveRaw(key); });
|
|
}
|
|
|
|
std::vector<Result<void>>
|
|
storeBatch(const std::vector<std::pair<std::string, std::vector<std::byte>>>& items) override {
|
|
++storeBatchCalls;
|
|
std::vector<Result<void>> 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<uint64_t> getStorageSize() const override {
|
|
uint64_t totalBytes = 0;
|
|
for (const auto& [_, data] : objects_) {
|
|
totalBytes += data.size();
|
|
}
|
|
return totalBytes;
|
|
}
|
|
|
|
Result<std::vector<std::string>> list(std::string_view = "") const override {
|
|
std::vector<std::string> keys;
|
|
for (const auto& [k, _] : objects_) {
|
|
keys.push_back(k);
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
bool empty() const { return objects_.empty(); }
|
|
|
|
private:
|
|
std::unordered_map<std::string, std::vector<std::byte>> objects_;
|
|
};
|
|
|
|
class FailingFileChunker final : public yams::chunking::IChunker {
|
|
public:
|
|
size_t chunkFileCalls{0};
|
|
|
|
const yams::chunking::ChunkingConfig& getConfig() const override { return config_; }
|
|
|
|
std::vector<yams::chunking::Chunk> chunkFile(const fs::path&) override {
|
|
++chunkFileCalls;
|
|
throw std::runtime_error("scripted file chunk failure");
|
|
}
|
|
|
|
std::vector<yams::chunking::Chunk> chunkData(yams::span<const std::byte> data) override {
|
|
std::vector<std::byte> 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<yams::chunking::Chunk> chunkDataLazy(yams::span<const std::byte> data) override {
|
|
return chunkData(data);
|
|
}
|
|
|
|
std::future<Result<std::vector<yams::chunking::Chunk>>>
|
|
chunkFileAsync(const fs::path&) override {
|
|
return std::async(std::launch::deferred,
|
|
[]() -> Result<std::vector<yams::chunking::Chunk>> {
|
|
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<void> increment(std::string_view, size_t, size_t) override { return {}; }
|
|
Result<void> decrement(std::string_view) override {
|
|
++immediateDecrements;
|
|
return {};
|
|
}
|
|
Result<uint64_t> getRefCount(std::string_view) const override { return uint64_t{0}; }
|
|
Result<bool> hasReferences(std::string_view) const override { return false; }
|
|
|
|
Result<yams::storage::RefCountStats> getStats() const override {
|
|
return yams::storage::RefCountStats{};
|
|
}
|
|
|
|
std::unique_ptr<ITransaction> 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<void> 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<yams::storage::IReferenceCounter::ITransaction>
|
|
ScriptedReferenceCounter::beginTransaction() {
|
|
++beginCalls;
|
|
return std::make_unique<Transaction>(*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<char>(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<const char*>(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<std::byte> data;
|
|
data.reserve(content.size());
|
|
for (char c : content) {
|
|
data.push_back(static_cast<std::byte>(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<const char*>(retrieveResult.value().data()),
|
|
retrieveResult.value().size());
|
|
CHECK(retrieved == content);
|
|
}
|
|
|
|
SECTION("Retrieve directly stored bytes to a file") {
|
|
const std::string content = "direct memory payload";
|
|
const auto data = std::span<const std::byte>(
|
|
reinterpret_cast<const std::byte*>(content.data()), content.size());
|
|
|
|
auto stored = fixture.store_->storeBytes(data);
|
|
REQUIRE(stored.has_value());
|
|
|
|
const auto outputPath = fixture.testDir_ / "retrieved-memory.bin";
|
|
auto retrieved = fixture.store_->retrieve(stored.value().contentHash, outputPath);
|
|
REQUIRE(retrieved.has_value());
|
|
REQUIRE(fs::exists(outputPath));
|
|
|
|
std::ifstream input(outputPath, std::ios::binary);
|
|
const std::string output{std::istreambuf_iterator<char>{input},
|
|
std::istreambuf_iterator<char>{}};
|
|
CHECK(output == content);
|
|
}
|
|
|
|
SECTION("Direct retrieval reports close-time output failures") {
|
|
#ifndef __linux__
|
|
SKIP("/dev/full close-time failure fixture is Linux-only");
|
|
#else
|
|
const std::string content = "direct close failure";
|
|
const auto data = std::span<const std::byte>(
|
|
reinterpret_cast<const std::byte*>(content.data()), content.size());
|
|
auto stored = fixture.store_->storeBytes(data);
|
|
REQUIRE(stored.has_value());
|
|
|
|
auto retrieved = fixture.store_->retrieve(stored.value().contentHash, "/dev/full");
|
|
REQUIRE_FALSE(retrieved.has_value());
|
|
CHECK(retrieved.error().code == ErrorCode::WriteError);
|
|
#endif
|
|
}
|
|
|
|
SECTION("Store binary bytes with null characters") {
|
|
std::vector<std::byte> 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<std::byte> data;
|
|
data.reserve(content.size());
|
|
for (char c : content) {
|
|
data.push_back(static_cast<std::byte>(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<std::byte> data;
|
|
data.reserve(content.size());
|
|
for (char c : content) {
|
|
data.push_back(static_cast<std::byte>(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<std::byte> 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<fs::path> files;
|
|
std::vector<ContentMetadata> 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<std::string> 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<int>(hashes.size()));
|
|
}
|
|
|
|
SECTION("Batch remove") {
|
|
std::vector<std::string> 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<ScriptedStorageEngine>();
|
|
auto refCounter = std::make_shared<ScriptedReferenceCounter>();
|
|
|
|
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<fs::path> 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<const std::byte>(reinterpret_cast<const std::byte*>(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<ScriptedStorageEngine>();
|
|
auto refCounter = std::make_shared<ScriptedReferenceCounter>();
|
|
|
|
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<ScriptedStorageEngine>();
|
|
auto refCounter = std::make_shared<ScriptedReferenceCounter>();
|
|
|
|
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<ScriptedStorageEngine>();
|
|
auto refCounter = std::make_shared<ScriptedReferenceCounter>();
|
|
|
|
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<fs::path> 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<ContentMetadata> 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<ScriptedStorageEngine>();
|
|
auto refCounter = std::make_shared<ScriptedReferenceCounter>();
|
|
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<fs::path> files;
|
|
std::vector<std::string> 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<int> progressCalls{0};
|
|
std::atomic<uint64_t> 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<double> 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<ScriptedStorageEngine>();
|
|
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<std::byte> 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<yams::storage::IStorageEngine>{})
|
|
.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<ScriptedStorageEngine>();
|
|
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<std::byte> data(MIN_CHUNK_SIZE + 1024);
|
|
for (size_t i = 0; i < data.size(); ++i) {
|
|
data[i] = static_cast<std::byte>(i & 0xff);
|
|
}
|
|
const auto hash =
|
|
yams::crypto::SHA256Hasher::hash(std::span<const std::byte>(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<ScriptedStorageEngine>();
|
|
auto chunker = std::make_shared<FailingFileChunker>();
|
|
|
|
{
|
|
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<ScriptedStorageEngine>();
|
|
auto refCounter = std::make_shared<ScriptedReferenceCounter>();
|
|
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<std::byte> data(MIN_CHUNK_SIZE + 2048);
|
|
for (size_t i = 0; i < data.size(); ++i) {
|
|
data[i] = static_cast<std::byte>((i * 17) & 0xff);
|
|
}
|
|
const auto hash =
|
|
yams::crypto::SHA256Hasher::hash(std::span<const std::byte>(data.data(), data.size()));
|
|
|
|
auto path = tempDir / "commit-fail.bin";
|
|
std::ofstream file(path, std::ios::binary);
|
|
file.write(reinterpret_cast<const char*>(data.data()),
|
|
static_cast<std::streamsize>(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<ScriptedStorageEngine>();
|
|
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<std::byte> data(MIN_CHUNK_SIZE + 2048);
|
|
for (size_t i = 0; i < data.size(); ++i) {
|
|
data[i] = static_cast<std::byte>((i * 13) & 0xff);
|
|
}
|
|
const auto hash =
|
|
yams::crypto::SHA256Hasher::hash(std::span<const std::byte>(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<ScriptedStorageEngine>();
|
|
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<std::byte> data(MIN_CHUNK_SIZE + 2048);
|
|
for (size_t i = 0; i < data.size(); ++i) {
|
|
data[i] = static_cast<std::byte>((i * 17) & 0xff);
|
|
}
|
|
const auto hash =
|
|
yams::crypto::SHA256Hasher::hash(std::span<const std::byte>(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<std::byte> small(1024, std::byte{0x21});
|
|
auto smallStore = fixture.store_->storeBytes(small);
|
|
REQUIRE(smallStore.has_value());
|
|
|
|
std::vector<std::byte> 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<std::byte> 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<bool>(file));
|
|
file.seekp(0);
|
|
const char corrupt = static_cast<char>(0xff);
|
|
file.write(&corrupt, 1);
|
|
REQUIRE(static_cast<bool>(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<std::byte> large(DEFAULT_CHUNK_SIZE + 32768, std::byte{0x44});
|
|
for (size_t i = 0; i < large.size(); ++i) {
|
|
large[i] = static_cast<std::byte>(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<Progress> 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<std::thread> threads;
|
|
std::atomic<int> 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<std::thread> threads;
|
|
std::atomic<int> successCount{0};
|
|
std::mutex resultMutex;
|
|
std::vector<std::string> hashes;
|
|
std::vector<std::string> errors;
|
|
hashes.reserve(static_cast<std::size_t>(kThreadCount) *
|
|
static_cast<std::size_t>(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<std::mutex> lock(resultMutex);
|
|
hashes.push_back(result.value().contentHash);
|
|
} else {
|
|
std::lock_guard<std::mutex> 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<std::byte> data;
|
|
data.reserve(content.size());
|
|
for (char c : content) {
|
|
data.push_back(static_cast<std::byte>(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<int> 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
|