mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-28 22:01:12 +00:00
427 lines
16 KiB
C++
427 lines
16 KiB
C++
#include <doctest/doctest.h>
|
|
|
|
// LocalPreviewByteCache is the only ccm_core test in this binary that
|
|
// exercises the real filesystem - by design. The adapter's reason for
|
|
// existing is to translate IPreviewByteCache calls into actual on-disk
|
|
// state (binary payloads, sidecar key files, mtime-driven LRU eviction
|
|
// via std::filesystem), and stubbing those primitives just to test the
|
|
// in-memory bookkeeping would defeat the whole point. We pin every test
|
|
// to a fresh, unique directory under the OS temp path and clean it up
|
|
// even on assertion failure.
|
|
|
|
#include "ccm/infra/LocalPreviewByteCache.hpp"
|
|
#include "ccm/infra/StdFileSystem.hpp"
|
|
|
|
#include "fakes/InMemoryFileSystem.hpp"
|
|
|
|
#include <chrono>
|
|
#include <filesystem>
|
|
#include <random>
|
|
#include <string>
|
|
#include <thread>
|
|
|
|
using namespace ccm;
|
|
namespace fs = std::filesystem;
|
|
|
|
namespace {
|
|
|
|
// Creates and removes a unique temp directory with strong cleanup
|
|
// guarantees. Doctest does not have RAII fixtures by default; this struct
|
|
// provides the same effect via destructor.
|
|
struct TempDir {
|
|
fs::path path;
|
|
|
|
TempDir() {
|
|
std::random_device rd;
|
|
const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count();
|
|
path = fs::temp_directory_path() /
|
|
(std::string("ccm_preview_cache_test_") + std::to_string(stamp) +
|
|
"_" + std::to_string(rd()));
|
|
std::error_code ec;
|
|
fs::create_directories(path, ec);
|
|
}
|
|
~TempDir() {
|
|
std::error_code ec;
|
|
fs::remove_all(path, ec);
|
|
}
|
|
|
|
TempDir(const TempDir&) = delete;
|
|
TempDir& operator=(const TempDir&) = delete;
|
|
};
|
|
|
|
// Touch helper: nudges a file's mtime backwards so eviction-by-oldest is
|
|
// deterministic regardless of FS timestamp resolution.
|
|
void backdate(const fs::path& p, int seconds) {
|
|
std::error_code ec;
|
|
auto t = fs::last_write_time(p, ec);
|
|
if (ec) return;
|
|
fs::last_write_time(p, t - std::chrono::seconds(seconds), ec);
|
|
}
|
|
|
|
class FailingEnsureDirFs final : public IFileSystem {
|
|
public:
|
|
explicit FailingEnsureDirFs(ccm::testing::InMemoryFileSystem& inner) : inner_(inner) {}
|
|
|
|
[[nodiscard]] bool exists(const fs::path& p) const override { return inner_.exists(p); }
|
|
[[nodiscard]] bool isDirectory(const fs::path& p) const override { return inner_.isDirectory(p); }
|
|
Result<void> ensureDirectory(const fs::path& p) override {
|
|
(void)p;
|
|
return Result<void>::err("ensure failed");
|
|
}
|
|
Result<std::string> readText(const fs::path& p) override { return inner_.readText(p); }
|
|
Result<void> writeText(const fs::path& p, std::string_view contents) override {
|
|
return inner_.writeText(p, contents);
|
|
}
|
|
Result<void> copyFile(const fs::path& from, const fs::path& to, bool overwrite) override {
|
|
return inner_.copyFile(from, to, overwrite);
|
|
}
|
|
Result<void> remove(const fs::path& p) override { return inner_.remove(p); }
|
|
Result<std::vector<fs::path>> listDirectory(const fs::path& p) override {
|
|
return inner_.listDirectory(p);
|
|
}
|
|
|
|
private:
|
|
ccm::testing::InMemoryFileSystem& inner_;
|
|
};
|
|
|
|
class FailingIndexWriteFs final : public IFileSystem {
|
|
public:
|
|
explicit FailingIndexWriteFs(ccm::testing::InMemoryFileSystem& inner) : inner_(inner) {}
|
|
|
|
[[nodiscard]] bool exists(const fs::path& p) const override { return inner_.exists(p); }
|
|
[[nodiscard]] bool isDirectory(const fs::path& p) const override { return inner_.isDirectory(p); }
|
|
Result<void> ensureDirectory(const fs::path& p) override { return inner_.ensureDirectory(p); }
|
|
Result<std::string> readText(const fs::path& p) override { return inner_.readText(p); }
|
|
Result<void> writeText(const fs::path& p, std::string_view contents) override {
|
|
const auto path = p.generic_string();
|
|
if (path.size() >= 4 && path.compare(path.size() - 4, 4, ".idx") == 0) {
|
|
return Result<void>::err("idx write failed");
|
|
}
|
|
return inner_.writeText(p, contents);
|
|
}
|
|
Result<void> copyFile(const fs::path& from, const fs::path& to, bool overwrite) override {
|
|
return inner_.copyFile(from, to, overwrite);
|
|
}
|
|
Result<void> remove(const fs::path& p) override { return inner_.remove(p); }
|
|
Result<std::vector<fs::path>> listDirectory(const fs::path& p) override {
|
|
return inner_.listDirectory(p);
|
|
}
|
|
|
|
private:
|
|
ccm::testing::InMemoryFileSystem& inner_;
|
|
};
|
|
|
|
} // namespace
|
|
|
|
TEST_SUITE("LocalPreviewByteCache") {
|
|
TEST_CASE("store then load round-trips bytes verbatim") {
|
|
TempDir td;
|
|
StdFileSystem fs;
|
|
LocalPreviewByteCache cache(fs, td.path);
|
|
|
|
const std::string key = "magic|Lightning Bolt|lea|161";
|
|
const std::string body = std::string("\x89PNG\r\n\x1a\n", 8) + std::string(2048, '\xab');
|
|
|
|
cache.store(key, body);
|
|
const auto loaded = cache.load(key);
|
|
REQUIRE(loaded.kind == IPreviewByteCache::HitKind::Hit);
|
|
CHECK(loaded.payload == body);
|
|
}
|
|
|
|
TEST_CASE("missing key is a clean miss, not an error") {
|
|
TempDir td;
|
|
StdFileSystem fs;
|
|
LocalPreviewByteCache cache(fs, td.path);
|
|
|
|
CHECK(cache.load("never-stored").kind == IPreviewByteCache::HitKind::Miss);
|
|
}
|
|
|
|
TEST_CASE("empty payload is silently skipped") {
|
|
// Empty would be an indistinguishable miss anyway, and writing it
|
|
// would waste an inode. The contract is: we treat it as a no-op.
|
|
TempDir td;
|
|
StdFileSystem fs;
|
|
LocalPreviewByteCache cache(fs, td.path);
|
|
|
|
cache.store("k", "");
|
|
CHECK(cache.load("k").kind == IPreviewByteCache::HitKind::Miss);
|
|
CHECK(cache.currentSizeBytes() == 0);
|
|
}
|
|
|
|
TEST_CASE("hash collision check via sidecar mismatch is treated as a miss") {
|
|
// We fake a collision by writing the same hash file with a wrong
|
|
// sidecar key. Even though the .bin is present, load() must
|
|
// reject it - otherwise we'd serve the wrong card's bytes, which
|
|
// is much worse than re-fetching.
|
|
TempDir td;
|
|
StdFileSystem fs;
|
|
LocalPreviewByteCache cache(fs, td.path);
|
|
|
|
cache.store("real-key", "REAL");
|
|
// Find the .idx and corrupt the key inside.
|
|
for (const auto& entry : fs::directory_iterator(td.path)) {
|
|
if (entry.path().extension() == ".idx") {
|
|
std::error_code ec;
|
|
fs::remove(entry.path(), ec);
|
|
StdFileSystem io;
|
|
(void)io.writeText(entry.path(), "different-key");
|
|
break;
|
|
}
|
|
}
|
|
|
|
CHECK(cache.load("real-key").kind == IPreviewByteCache::HitKind::Miss);
|
|
}
|
|
|
|
TEST_CASE("survives an adapter restart over the same directory") {
|
|
// The whole point of the disk tier is persistence. Exercise it by
|
|
// dropping one cache instance and bringing up a new one against
|
|
// the same path - the previously-stored entry must come back.
|
|
TempDir td;
|
|
StdFileSystem fs;
|
|
const std::string key = "ygo|Dark Magician|SDY|6|1E";
|
|
const std::string body(4096, 'D');
|
|
|
|
{
|
|
LocalPreviewByteCache writer(fs, td.path);
|
|
writer.store(key, body);
|
|
}
|
|
{
|
|
LocalPreviewByteCache reader(fs, td.path);
|
|
const auto loaded = reader.load(key);
|
|
REQUIRE(loaded.kind == IPreviewByteCache::HitKind::Hit);
|
|
CHECK(loaded.payload == body);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("storeNegative round-trips as NegativeHit, not a miss and not a payload") {
|
|
// Negative entries are the "we asked, the upstream said no image"
|
|
// marker. They must round-trip as their own load kind so the
|
|
// service can short-circuit without re-resolving the URL.
|
|
TempDir td;
|
|
StdFileSystem fs;
|
|
LocalPreviewByteCache cache(fs, td.path);
|
|
|
|
const std::string key = "magic|Bogus Card|lea|";
|
|
cache.storeNegative(key);
|
|
|
|
const auto loaded = cache.load(key);
|
|
CHECK(loaded.kind == IPreviewByteCache::HitKind::NegativeHit);
|
|
CHECK(loaded.payload.empty());
|
|
CHECK(cache.currentSizeBytes() == 0); // negatives don't take real space
|
|
}
|
|
|
|
TEST_CASE("negative entries survive an adapter restart") {
|
|
// The "warm restart, same negative answer instantly" property is
|
|
// the whole reason negatives go to disk. Verify it explicitly so
|
|
// it can never silently regress.
|
|
TempDir td;
|
|
StdFileSystem fs;
|
|
const std::string key = "ygo|Truly Missing|SET|999||UE";
|
|
|
|
{
|
|
LocalPreviewByteCache writer(fs, td.path);
|
|
writer.storeNegative(key);
|
|
}
|
|
{
|
|
LocalPreviewByteCache reader(fs, td.path);
|
|
CHECK(reader.load(key).kind == IPreviewByteCache::HitKind::NegativeHit);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("a later positive store overwrites an earlier negative entry") {
|
|
// Upstream eventually grows a scan: the next positive store has
|
|
// to flip the entry. If the .neg lingered we'd keep returning a
|
|
// NegativeHit even after a successful network resolution, which
|
|
// would defeat the recovery story.
|
|
TempDir td;
|
|
StdFileSystem fs;
|
|
LocalPreviewByteCache cache(fs, td.path);
|
|
|
|
const std::string key = "magic|Late|lea|";
|
|
const std::string body = std::string(1024, 'L');
|
|
|
|
cache.storeNegative(key);
|
|
REQUIRE(cache.load(key).kind == IPreviewByteCache::HitKind::NegativeHit);
|
|
|
|
cache.store(key, body);
|
|
const auto loaded = cache.load(key);
|
|
REQUIRE(loaded.kind == IPreviewByteCache::HitKind::Hit);
|
|
CHECK(loaded.payload == body);
|
|
}
|
|
|
|
TEST_CASE("storeNegative for an existing positive entry replaces the bytes") {
|
|
// Symmetric to the previous case: an upstream that *had* a scan
|
|
// and now reports "no image" (e.g. file deleted) flips the entry
|
|
// back to negative. The .bin must go away so it doesn't keep
|
|
// serving stale bytes and doesn't keep wasting cap space.
|
|
TempDir td;
|
|
StdFileSystem fs;
|
|
LocalPreviewByteCache cache(fs, td.path);
|
|
|
|
const std::string key = "magic|Recall|lea|";
|
|
cache.store(key, std::string(2048, 'R'));
|
|
REQUIRE(cache.currentSizeBytes() > 0);
|
|
|
|
cache.storeNegative(key);
|
|
const auto loaded = cache.load(key);
|
|
CHECK(loaded.kind == IPreviewByteCache::HitKind::NegativeHit);
|
|
CHECK(cache.currentSizeBytes() == 0);
|
|
}
|
|
|
|
TEST_CASE("collision check applies to negative entries too") {
|
|
// A hash collision must not let a negative entry stored under
|
|
// key A "leak" into a load of key B. We fake the collision by
|
|
// rewriting the .idx sidecar with a different key.
|
|
TempDir td;
|
|
StdFileSystem fs;
|
|
LocalPreviewByteCache cache(fs, td.path);
|
|
|
|
cache.storeNegative("real-key");
|
|
for (const auto& entry : fs::directory_iterator(td.path)) {
|
|
if (entry.path().extension() == ".idx") {
|
|
std::error_code ec;
|
|
fs::remove(entry.path(), ec);
|
|
StdFileSystem io;
|
|
(void)io.writeText(entry.path(), "different-key");
|
|
break;
|
|
}
|
|
}
|
|
CHECK(cache.load("real-key").kind == IPreviewByteCache::HitKind::Miss);
|
|
}
|
|
|
|
TEST_CASE("entry with payload but missing sidecar is treated as miss") {
|
|
TempDir td;
|
|
StdFileSystem fs;
|
|
LocalPreviewByteCache cache(fs, td.path);
|
|
cache.store("real-key", "REAL");
|
|
|
|
for (const auto& entry : fs::directory_iterator(td.path)) {
|
|
if (entry.path().extension() == ".idx") {
|
|
std::error_code ec;
|
|
fs::remove(entry.path(), ec);
|
|
}
|
|
}
|
|
|
|
CHECK(cache.load("real-key").kind == IPreviewByteCache::HitKind::Miss);
|
|
}
|
|
|
|
TEST_CASE("entry with negative marker but missing sidecar is treated as miss") {
|
|
TempDir td;
|
|
StdFileSystem fs;
|
|
LocalPreviewByteCache cache(fs, td.path);
|
|
cache.storeNegative("real-key");
|
|
|
|
for (const auto& entry : fs::directory_iterator(td.path)) {
|
|
if (entry.path().extension() == ".idx") {
|
|
std::error_code ec;
|
|
fs::remove(entry.path(), ec);
|
|
}
|
|
}
|
|
|
|
CHECK(cache.load("real-key").kind == IPreviewByteCache::HitKind::Miss);
|
|
}
|
|
|
|
TEST_CASE("entry with unreadable payload file is treated as miss") {
|
|
TempDir td;
|
|
StdFileSystem fs;
|
|
LocalPreviewByteCache cache(fs, td.path);
|
|
cache.store("real-key", "REAL");
|
|
|
|
for (const auto& entry : fs::directory_iterator(td.path)) {
|
|
if (entry.path().extension() == ".bin") {
|
|
std::error_code ec;
|
|
fs::remove(entry.path(), ec);
|
|
break;
|
|
}
|
|
}
|
|
|
|
CHECK(cache.load("real-key").kind == IPreviewByteCache::HitKind::Miss);
|
|
}
|
|
|
|
TEST_CASE("evicts oldest entry when the size cap would be exceeded") {
|
|
TempDir td;
|
|
StdFileSystem fs;
|
|
// Cap is just big enough to hold 2 of the 1 KiB payloads but not 3.
|
|
constexpr std::size_t kCap = 2'400;
|
|
LocalPreviewByteCache cache(fs, td.path, kCap);
|
|
|
|
const std::string a(1024, 'a');
|
|
const std::string b(1024, 'b');
|
|
const std::string c(1024, 'c');
|
|
|
|
cache.store("k-a", a);
|
|
// Make 'a' look definitively older than 'b' regardless of the FS's
|
|
// mtime resolution (some filesystems round to whole seconds, which
|
|
// can otherwise make the test flaky).
|
|
for (const auto& entry : fs::directory_iterator(td.path)) {
|
|
backdate(entry.path(), /*seconds=*/10);
|
|
}
|
|
|
|
cache.store("k-b", b);
|
|
cache.store("k-c", c); // would push us to ~3 KiB; 'a' must go.
|
|
|
|
CHECK(cache.load("k-a").kind == IPreviewByteCache::HitKind::Miss);
|
|
CHECK(cache.load("k-b").kind == IPreviewByteCache::HitKind::Hit);
|
|
CHECK(cache.load("k-c").kind == IPreviewByteCache::HitKind::Hit);
|
|
CHECK(cache.currentSizeBytes() <= kCap);
|
|
}
|
|
|
|
TEST_CASE("a hit refreshes mtime so the entry is not the next eviction victim") {
|
|
// Without the touch-on-load behavior, an entry that the user
|
|
// re-views constantly would still age out simply because newer
|
|
// entries piled on top. Verify we promote on hit.
|
|
TempDir td;
|
|
StdFileSystem fs;
|
|
constexpr std::size_t kCap = 2'400;
|
|
LocalPreviewByteCache cache(fs, td.path, kCap);
|
|
|
|
const std::string a(1024, 'a');
|
|
const std::string b(1024, 'b');
|
|
const std::string c(1024, 'c');
|
|
|
|
cache.store("k-a", a);
|
|
cache.store("k-b", b);
|
|
// Backdate both so the upcoming touch on 'a' is unambiguously newer.
|
|
for (const auto& entry : fs::directory_iterator(td.path)) {
|
|
backdate(entry.path(), /*seconds=*/10);
|
|
}
|
|
|
|
REQUIRE(cache.load("k-a").kind == IPreviewByteCache::HitKind::Hit); // touches 'a' to "now".
|
|
cache.store("k-c", c); // forces an eviction.
|
|
|
|
// 'a' was the youngest after the touch, so 'b' should be the victim.
|
|
CHECK(cache.load("k-b").kind == IPreviewByteCache::HitKind::Miss);
|
|
CHECK(cache.load("k-a").kind == IPreviewByteCache::HitKind::Hit);
|
|
CHECK(cache.load("k-c").kind == IPreviewByteCache::HitKind::Hit);
|
|
}
|
|
}
|
|
|
|
TEST_SUITE("LocalPreviewByteCache in-memory filesystem failures") {
|
|
TEST_CASE("store is a silent no-op when ensureDirectory fails") {
|
|
ccm::testing::InMemoryFileSystem inner;
|
|
FailingEnsureDirFs fs{inner};
|
|
LocalPreviewByteCache cache(fs, "/cache");
|
|
|
|
cache.store("k", "payload");
|
|
CHECK(cache.load("k").kind == IPreviewByteCache::HitKind::Miss);
|
|
}
|
|
|
|
TEST_CASE("store rolls back payload when sidecar write fails") {
|
|
ccm::testing::InMemoryFileSystem inner;
|
|
FailingIndexWriteFs fs{inner};
|
|
LocalPreviewByteCache cache(fs, "/cache");
|
|
|
|
cache.store("k", "payload");
|
|
CHECK(cache.load("k").kind == IPreviewByteCache::HitKind::Miss);
|
|
}
|
|
|
|
TEST_CASE("storeNegative rolls back marker when sidecar write fails") {
|
|
ccm::testing::InMemoryFileSystem inner;
|
|
FailingIndexWriteFs fs{inner};
|
|
LocalPreviewByteCache cache(fs, "/cache");
|
|
|
|
cache.storeNegative("k");
|
|
CHECK(cache.load("k").kind == IPreviewByteCache::HitKind::Miss);
|
|
}
|
|
}
|