930 lines
32 KiB
C++
930 lines
32 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 <catch2/matchers/catch_matchers_floating_point.hpp>
|
|
#include <catch2/matchers/catch_matchers_string.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 <algorithm>
|
|
#include <atomic>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <random>
|
|
#include <sstream>
|
|
#include <thread>
|
|
|
|
namespace yams::api::test {
|
|
|
|
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>());
|
|
}
|
|
};
|
|
|
|
// =============================================================================
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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") == "日本語");
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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("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});
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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());
|
|
}
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Progress Reporting Tests
|
|
// =============================================================================
|
|
|
|
TEST_CASE("ContentStore: Progress reporting", "[api][content-store][progress]") {
|
|
ContentStoreFixture fixture;
|
|
|
|
SECTION("Progress callback invoked") {
|
|
std::string content(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
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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 = 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(kThreadCount * 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());
|
|
}
|
|
}
|
|
}
|
|
|
|
} // namespace yams::api::test
|