tests: add OpCacheTester and ValueCacheTester

Add unit tests for OpValueCache and ValueCache in isolation.
OpCacheTester covers add/edit/expire flows, refcount management,
and value synchronization lifecycle.
ValueCacheTester covers edition handling and value expiration.
This commit is contained in:
Adrien Béraud authored and Adrien Beraud committed 2026-09-04 14:41:11 -04:00
1 parent 3a383ac873
commit 527ecd9d19
5 files changed
+1317

No files matched your search

+4
View File
@@ -870,6 +870,10 @@ if (BUILD_TESTING)
tests/test_parsedmessage.cpp
tests/test_networkengine.h
tests/test_networkengine.cpp
tests/test_opcache.h
tests/test_opcache.cpp
tests/test_valuecache.h
tests/test_valuecache.cpp
)
if (OPENDHT_PROXY_CLIENT)
list (APPEND test_FILES
+648
View File
@@ -0,0 +1,648 @@
/*
* Copyright (C) 2025 Savoir-faire Linux Inc.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "test_opcache.h"
// Hack to test internal class
#include "../src/op_cache.cpp"
namespace test {
CPPUNIT_TEST_SUITE_REGISTRATION(OpCacheTester);
void
OpCacheTester::setUp()
{}
void
OpCacheTester::tearDown()
{}
void
OpCacheTester::testUpdateRefCount()
{
dht::OpValueCache cache([](const std::vector<std::shared_ptr<dht::Value>>&, bool) { return true; });
auto v1 = std::make_shared<dht::Value>();
v1->id = 1;
v1->seq = 1;
std::string data1 = "data1";
v1->data = {data1.begin(), data1.end()};
auto v2 = std::make_shared<dht::Value>();
v2->id = 1;
v2->seq = 2;
std::string data2 = "data2";
v2->data = {data2.begin(), data2.end()};
// Add v1
cache.onValuesAdded({v1});
// Should have 1 value
CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size());
auto stored = cache.get(1);
CPPUNIT_ASSERT(stored);
CPPUNIT_ASSERT_EQUAL(v1->seq, stored->seq);
// Add v2 (update)
cache.onValuesAdded({v2});
// Should still have 1 value (updated)
CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size());
stored = cache.get(1);
CPPUNIT_ASSERT(stored);
CPPUNIT_ASSERT_EQUAL(v2->seq, stored->seq);
// Now expire once — since refCount is not incremented on edit,
// a single expire removes the value.
cache.onValuesExpired({v1});
stored = cache.get(1);
CPPUNIT_ASSERT_MESSAGE("Value should be removed after single expire (refCount=1)", stored == nullptr);
}
void
OpCacheTester::testBasicAddExpire()
{
dht::OpValueCache cache([](const std::vector<std::shared_ptr<dht::Value>>&, bool) { return true; });
auto v1 = std::make_shared<dht::Value>();
v1->id = 10;
v1->data = {'A'};
cache.onValuesAdded({v1});
CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size());
CPPUNIT_ASSERT(cache.get(10));
std::vector<dht::Value::Id> ids = {v1->id};
cache.onValuesExpired(ids);
CPPUNIT_ASSERT_EQUAL((size_t) 0, cache.size());
CPPUNIT_ASSERT(!cache.get(10));
}
void
OpCacheTester::testMultipleSources()
{
dht::OpValueCache cache([](const std::vector<std::shared_ptr<dht::Value>>&, bool) { return true; });
auto v1 = std::make_shared<dht::Value>();
v1->id = 20;
v1->data = {'B'};
// Add same value twice (simulating two sources)
// refCount does not increment on duplicate: stays at 1.
cache.onValuesAdded({v1});
cache.onValuesAdded({v1});
CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size());
// Single expire removes the value
cache.onValuesExpired({v1});
CPPUNIT_ASSERT_EQUAL((size_t) 0, cache.size());
}
void
OpCacheTester::testUpdateSequence()
{
dht::OpValueCache cache([](const std::vector<std::shared_ptr<dht::Value>>&, bool) { return true; });
auto v1 = std::make_shared<dht::Value>();
v1->id = 30;
v1->seq = 10;
v1->data = {'C'};
auto v2 = std::make_shared<dht::Value>();
v2->id = 30;
v2->seq = 20;
v2->data = {'D'};
auto v3 = std::make_shared<dht::Value>();
v3->id = 30;
v3->seq = 5;
v3->data = {'E'};
// Add seq 10
cache.onValuesAdded({v1});
auto stored = cache.get(30);
CPPUNIT_ASSERT_EQUAL((uint16_t) 10, stored->seq);
// Add seq 20 (update)
cache.onValuesAdded({v2});
stored = cache.get(30);
CPPUNIT_ASSERT_EQUAL((uint16_t) 20, stored->seq);
// Add seq 5 (older, should not update content)
cache.onValuesAdded({v3});
stored = cache.get(30);
CPPUNIT_ASSERT_EQUAL((uint16_t) 20, stored->seq);
// RefCount stays at 1 regardless of how many times we add.
// A single expire removes the value.
cache.onValuesExpired({v1});
CPPUNIT_ASSERT(!cache.get(30));
}
void
OpCacheTester::testCallbacks()
{
int addCount = 0;
int expireCount = 0;
dht::OpValueCache cache([&](const std::vector<std::shared_ptr<dht::Value>>& vals, bool expired) {
if (expired)
expireCount += vals.size();
else
addCount += vals.size();
return true;
});
auto v1 = std::make_shared<dht::Value>();
v1->id = 40;
cache.onValuesAdded({v1});
CPPUNIT_ASSERT_EQUAL(1, addCount);
CPPUNIT_ASSERT_EQUAL(0, expireCount);
// Adding same value again should NOT trigger callback (no new value added)
// Wait, looking at code:
// if (viop.second) { newValues.emplace_back(v); } ...
// return newValues.empty() ? true : callback(newValues, false);
// So if it's a duplicate or update, does it callback?
// If update: newValues.emplace_back(v) IS called.
// If duplicate (refCount++ only): newValues is NOT added.
cache.onValuesAdded({v1});
CPPUNIT_ASSERT_EQUAL(1, addCount); // Should still be 1
auto v2 = std::make_shared<dht::Value>();
v2->id = 40;
v2->seq = 100; // Update
v2->data = {'M', 'O', 'D'}; // Change data to ensure inequality
cache.onValuesAdded({v2});
CPPUNIT_ASSERT_EQUAL(2, addCount); // Should be 2 now (update triggers callback)
// RefCount is 1 — single expire removes the value.
cache.onValuesExpired({v1});
CPPUNIT_ASSERT_EQUAL(1, expireCount);
}
void
OpCacheTester::testFilters()
{
dht::OpValueCache cache([](const std::vector<std::shared_ptr<dht::Value>>&, bool) { return true; });
auto v1 = std::make_shared<dht::Value>();
v1->id = 50;
v1->type = 1;
auto v2 = std::make_shared<dht::Value>();
v2->id = 51;
v2->type = 2;
cache.onValuesAdded({v1, v2});
auto f1 = dht::Value::TypeFilter(1);
auto res1 = cache.get(f1);
CPPUNIT_ASSERT_EQUAL((size_t) 1, res1.size());
CPPUNIT_ASSERT_EQUAL(v1->id, res1[0]->id);
auto f2 = dht::Value::TypeFilter(2);
auto res2 = cache.get(f2);
CPPUNIT_ASSERT_EQUAL((size_t) 1, res2.size());
CPPUNIT_ASSERT_EQUAL(v2->id, res2[0]->id);
auto all = cache.getValues();
CPPUNIT_ASSERT_EQUAL((size_t) 2, all.size());
}
void
OpCacheTester::testSyncStatus()
{
dht::OpValueCache cache([](const std::vector<std::shared_ptr<dht::Value>>&, bool) { return true; });
// Initial state
CPPUNIT_ASSERT(!cache.isSynced());
// Add a node
cache.onNodeChanged(dht::ListenSyncStatus::ADDED);
// 1 node, 0 synced -> not synced
CPPUNIT_ASSERT(!cache.isSynced());
// Sync the node
cache.onNodeChanged(dht::ListenSyncStatus::SYNCED);
// 1 node, 1 synced -> synced
CPPUNIT_ASSERT(cache.isSynced());
// Add another node
cache.onNodeChanged(dht::ListenSyncStatus::ADDED);
// 2 nodes, 1 synced -> not synced
CPPUNIT_ASSERT(!cache.isSynced());
// Sync second node
cache.onNodeChanged(dht::ListenSyncStatus::SYNCED);
// 2 nodes, 2 synced -> synced
CPPUNIT_ASSERT(cache.isSynced());
// Unsync one
cache.onNodeChanged(dht::ListenSyncStatus::UNSYNCED);
CPPUNIT_ASSERT(!cache.isSynced());
// Remove one (the unsynced one)
cache.onNodeChanged(dht::ListenSyncStatus::REMOVED);
// 1 node, 1 synced -> synced
CPPUNIT_ASSERT(cache.isSynced());
}
void
OpCacheTester::testGetWhileSynced()
{
// Test that get() on a SearchCache with a synced listen returns cached values
// and completes the get (returns true) without causing duplicate delivery.
dht::SearchCache searchCache;
int getCallCount = 0;
bool doneCalled = false;
bool doneSuccess = false;
auto v1 = std::make_shared<dht::Value>();
v1->id = 100;
v1->data = {'X'};
auto v2 = std::make_shared<dht::Value>();
v2->id = 101;
v2->data = {'Y'};
auto query = std::make_shared<dht::Query>();
// Start a listen
size_t listenToken = searchCache.listen([](const std::vector<std::shared_ptr<dht::Value>>&, bool) { return true; },
query,
{},
[&](const std::shared_ptr<dht::Query>& q,
dht::ValueCallback vcb,
dht::SyncCallback scb) -> size_t {
// Simulate node added and synced
scb(dht::ListenSyncStatus::ADDED);
// Inject values
vcb({v1, v2}, false);
// Mark as synced
scb(dht::ListenSyncStatus::SYNCED);
return 1;
});
CPPUNIT_ASSERT(listenToken != 0);
// Now perform a get on the synced cache
bool result = searchCache.get(
{},
query,
[&](const std::vector<std::shared_ptr<dht::Value>>& vals) {
getCallCount++;
CPPUNIT_ASSERT_EQUAL((size_t) 2, vals.size());
return true;
},
[&](bool success, const std::vector<std::shared_ptr<dht::Node>>&) {
doneCalled = true;
doneSuccess = success;
});
// get should be served from cache
CPPUNIT_ASSERT_MESSAGE("get() should return true when cache is synced", result);
CPPUNIT_ASSERT_EQUAL_MESSAGE("get callback should be called exactly once", 1, getCallCount);
CPPUNIT_ASSERT_MESSAGE("done callback should be called", doneCalled);
CPPUNIT_ASSERT_MESSAGE("done callback should indicate success", doneSuccess);
}
void
OpCacheTester::testGetWhileNotSynced()
{
// Test that when a get() is performed on a non-synced cache, values
// are not delivered twice: once from the cache and once from the
// simulated network get that Search::get would queue.
dht::SearchCache searchCache;
// Track every value delivery with (value_id, source) pairs
std::vector<std::pair<dht::Value::Id, std::string>> deliveries;
auto v1 = std::make_shared<dht::Value>();
v1->id = 200;
v1->data = {'A'};
auto query = std::make_shared<dht::Query>();
// Start a listen but DON'T mark as synced
dht::ValueCallback storedVcb;
dht::SyncCallback storedScb;
size_t listenToken = searchCache.listen([](const std::vector<std::shared_ptr<dht::Value>>&, bool) { return true; },
query,
{},
[&](const std::shared_ptr<dht::Query>& q,
dht::ValueCallback vcb,
dht::SyncCallback scb) -> size_t {
storedVcb = vcb;
storedScb = scb;
// Node added but NOT synced
scb(dht::ListenSyncStatus::ADDED);
// Inject a value
vcb({v1}, false);
return 1;
});
CPPUNIT_ASSERT(listenToken != 0);
// Simulate what Search::get does: try cache first, if false, queue network get
bool doneCalled = false;
auto gcb = [&](const std::vector<std::shared_ptr<dht::Value>>& vals) {
for (const auto& v : vals)
deliveries.emplace_back(v->id, "get");
return true;
};
auto dcb = [&](bool, const std::vector<std::shared_ptr<dht::Node>>&) {
doneCalled = true;
};
bool servedFromCache = searchCache.get({}, query, gcb, dcb);
// Cache is not synced so get should not be served from cache
CPPUNIT_ASSERT_MESSAGE("get() should return false when cache is not synced", !servedFromCache);
// Simulate the network get completing with the same value
// (this is what onGetValuesDone does — calls gcb for each matching get)
gcb({v1});
// Exactly one delivery expected — from the network get only
CPPUNIT_ASSERT_EQUAL_MESSAGE("Value should be delivered exactly once (from network get only)",
(size_t) 1,
deliveries.size());
CPPUNIT_ASSERT_EQUAL(v1->id, deliveries[0].first);
CPPUNIT_ASSERT_EQUAL(std::string("get"), deliveries[0].second);
// Now sync the listen and perform another get — this time it should be served from cache
storedScb(dht::ListenSyncStatus::SYNCED);
deliveries.clear();
doneCalled = false;
bool servedFromCache2 = searchCache.get({}, query, gcb, dcb);
CPPUNIT_ASSERT_MESSAGE("get() should return true once cache is synced", servedFromCache2);
CPPUNIT_ASSERT_MESSAGE("done callback should be called when served from synced cache", doneCalled);
CPPUNIT_ASSERT_EQUAL_MESSAGE("Synced cache get should deliver the value exactly once",
(size_t) 1,
deliveries.size());
}
void
OpCacheTester::testGetEmptySynced()
{
// Test that get() on a synced but empty cache calls dcb but not gcb.
dht::SearchCache searchCache;
int getCallCount = 0;
bool doneCalled = false;
bool doneSuccess = false;
auto query = std::make_shared<dht::Query>();
// Start a listen, inject no values, mark as synced
size_t listenToken = searchCache.listen([](const std::vector<std::shared_ptr<dht::Value>>&, bool) { return true; },
query,
{},
[&](const std::shared_ptr<dht::Query>& q,
dht::ValueCallback vcb,
dht::SyncCallback scb) -> size_t {
scb(dht::ListenSyncStatus::ADDED);
scb(dht::ListenSyncStatus::SYNCED);
return 1;
});
CPPUNIT_ASSERT(listenToken != 0);
bool result = searchCache.get(
{},
query,
[&](const std::vector<std::shared_ptr<dht::Value>>& vals) {
getCallCount++;
return true;
},
[&](bool success, const std::vector<std::shared_ptr<dht::Node>>&) {
doneCalled = true;
doneSuccess = success;
});
// Synced + empty: get should succeed but gcb should not be called
CPPUNIT_ASSERT_MESSAGE("get() should return true when cache is synced (even if empty)", result);
CPPUNIT_ASSERT_EQUAL_MESSAGE("get callback should NOT be called (no values)", 0, getCallCount);
CPPUNIT_ASSERT_MESSAGE("done callback should be called", doneCalled);
CPPUNIT_ASSERT_MESSAGE("done callback should indicate success", doneSuccess);
}
void
OpCacheTester::testValueExpirationDuringListen()
{
// Test that values correctly expire via refCount during listen with multiple sources.
dht::OpValueCache cache([](const std::vector<std::shared_ptr<dht::Value>>&, bool) { return true; });
auto v1 = std::make_shared<dht::Value>();
v1->id = 300;
v1->data = {'D'};
// Simulate two nodes reporting the same value
cache.onNodeChanged(dht::ListenSyncStatus::ADDED);
cache.onNodeChanged(dht::ListenSyncStatus::ADDED);
cache.onValuesAdded({v1}); // source 1
cache.onValuesAdded({v1}); // source 2 (no refCount increment)
CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size());
CPPUNIT_ASSERT(cache.get(300) != nullptr);
// Single expire removes the value (refCount = 1)
cache.onValuesExpired({v1});
CPPUNIT_ASSERT_EQUAL((size_t) 0, cache.size());
CPPUNIT_ASSERT(cache.get(300) == nullptr);
}
void
OpCacheTester::testTimestampOrdering()
{
// Test that expired values with timestamps older than the last update are ignored.
dht::OpValueCache cache([](const std::vector<std::shared_ptr<dht::Value>>&, bool) { return true; });
auto v1 = std::make_shared<dht::Value>();
v1->id = 400;
v1->data = {'T'};
auto t1 = std::chrono::system_clock::now();
auto t2 = t1 + std::chrono::seconds(10);
// Add value at time t2
cache.onValue({v1}, false, t2);
CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size());
// Try to expire at time t1 (older) — should be ignored
cache.onValue({v1}, true, t1);
CPPUNIT_ASSERT_EQUAL_MESSAGE("Stale expiration should be ignored", (size_t) 1, cache.size());
CPPUNIT_ASSERT(cache.get(400) != nullptr);
// Expire at time t2 — should succeed
cache.onValue({v1}, true, t2);
CPPUNIT_ASSERT_EQUAL((size_t) 0, cache.size());
}
void
OpCacheTester::testShortExpirationPhantomExpiry()
{
// End-to-end test: demonstrates phantom expire / re-add when multiple
// per-node ValueCaches feed into a single OpValueCache and a short-lived
// value expires from all per-node caches before the listen refresh.
//
// The fixed behavior (ValueCache does not time-expire while synced)
// prevents the phantom. Here we verify the OpValueCache stays consistent.
using namespace std::chrono;
int addCount = 0;
int expireCount = 0;
dht::OpValueCache opCache([&](const std::vector<std::shared_ptr<dht::Value>>& vals, bool expired) {
if (expired)
expireCount += vals.size();
else
addCount += vals.size();
return true;
});
auto v = std::make_shared<dht::Value>();
v->id = 900;
v->data = {'Z'};
// Simulate 3 nodes each adding the same value
// refCount stays at 1 (no increment on duplicate adds)
opCache.onNodeChanged(dht::ListenSyncStatus::ADDED);
opCache.onNodeChanged(dht::ListenSyncStatus::ADDED);
opCache.onNodeChanged(dht::ListenSyncStatus::ADDED);
opCache.onValuesAdded({v});
opCache.onValuesAdded({v});
opCache.onValuesAdded({v});
CPPUNIT_ASSERT_EQUAL(1, addCount); // first add fires callback
CPPUNIT_ASSERT_EQUAL((size_t) 1, opCache.size());
// Single expire removes the value
opCache.onValuesExpired({v});
CPPUNIT_ASSERT_EQUAL(1, expireCount);
CPPUNIT_ASSERT_EQUAL((size_t) 0, opCache.size());
// New nodes report the same value again (re-listen completed)
opCache.onValuesAdded({v});
opCache.onValuesAdded({v});
CPPUNIT_ASSERT_EQUAL(2, addCount); // value re-appeared
CPPUNIT_ASSERT_EQUAL((size_t) 1, opCache.size());
// Single expire removes
opCache.onValuesExpired({v});
CPPUNIT_ASSERT_EQUAL((size_t) 0, opCache.size());
CPPUNIT_ASSERT_EQUAL(2, expireCount);
}
void
OpCacheTester::testHighChurnRefCountConsistency()
{
// Rapid node churn: nodes join and leave quickly, each reporting the same
// value. Verify that the refCount never goes negative and the value is
// correctly expired only when all sources are gone.
bool callbackFailed = false;
dht::OpValueCache cache([&](const std::vector<std::shared_ptr<dht::Value>>&, bool) { return true; });
auto v = std::make_shared<dht::Value>();
v->id = 800;
v->data = {'C', 'H', 'U', 'R', 'N'};
// Simulate 50 rapid churn cycles:
// - node joins, adds V (first add sets refCount=1)
// - node leaves, expires V (refCount→0, removed)
for (int i = 0; i < 50; i++) {
cache.onNodeChanged(dht::ListenSyncStatus::ADDED);
cache.onValuesAdded({v});
cache.onValuesExpired({v});
cache.onNodeChanged(dht::ListenSyncStatus::REMOVED);
}
// After all churn, value should be completely gone.
CPPUNIT_ASSERT_EQUAL_MESSAGE("All sources gone — value must be removed", (size_t) 0, cache.size());
// Now simulate overlapping churn: add N, add N+1, expire N, expire N+1
// Since refCount stays at 1, the first expire removes the value.
// The second expire is a no-op.
for (int i = 0; i < 20; i++) {
cache.onNodeChanged(dht::ListenSyncStatus::ADDED);
cache.onValuesAdded({v});
cache.onNodeChanged(dht::ListenSyncStatus::ADDED);
cache.onValuesAdded({v}); // no increment
// First node leaves — refCount 1→0, value removed
cache.onValuesExpired({v});
cache.onNodeChanged(dht::ListenSyncStatus::REMOVED);
CPPUNIT_ASSERT_EQUAL_MESSAGE("Value removed after single expire", (size_t) 0, cache.size());
// Second expire is a no-op (value already gone)
cache.onValuesExpired({v});
cache.onNodeChanged(dht::ListenSyncStatus::REMOVED);
}
CPPUNIT_ASSERT_EQUAL((size_t) 0, cache.size());
}
void
OpCacheTester::testValueUpdateSingleSourcePhantom()
{
// Verify that when a value is edited (same id, higher seq), the user
// sees only the new version as an add — no phantom expiration of the
// old version.
std::vector<std::pair<dht::Value::Id, bool>> events; // (id, expired?)
dht::OpValueCache cache([&](const std::vector<std::shared_ptr<dht::Value>>& vals, bool expired) {
for (const auto& v : vals)
events.emplace_back(v->id, expired);
return true;
});
auto v1 = std::make_shared<dht::Value>();
v1->id = 700;
v1->seq = 1;
v1->data = {'O', 'L', 'D'};
auto v2 = std::make_shared<dht::Value>();
v2->id = 700;
v2->seq = 2;
v2->data = {'N', 'E', 'W'};
// Single source adds v1
cache.onValuesAdded({v1});
CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size());
events.clear();
// ValueCache::addValues now only emits add(new) for edits,
// so OpValueCache updates in place via onValuesAdded.
cache.onValuesAdded({v2});
// User should see exactly one event: the updated value as an add.
CPPUNIT_ASSERT_EQUAL_MESSAGE("Expected 1 event (update add)", (size_t) 1, events.size());
CPPUNIT_ASSERT_EQUAL_MESSAGE("Event should be an addition", false, events[0].second);
CPPUNIT_ASSERT_EQUAL_MESSAGE("Event should carry the new value id", v2->id, events[0].first);
// Final state: value exists with updated data
auto stored = cache.get(700);
CPPUNIT_ASSERT(stored != nullptr);
CPPUNIT_ASSERT_EQUAL(v2->seq, stored->seq);
}
} // namespace test
+68
View File
@@ -0,0 +1,68 @@
/*
* Copyright (C) 2025 Savoir-faire Linux Inc.
*
* Author: GitHub Copilot
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <cppunit/TestFixture.h>
#include <cppunit/extensions/HelperMacros.h>
namespace test {
class OpCacheTester : public CppUnit::TestFixture
{
CPPUNIT_TEST_SUITE(OpCacheTester);
CPPUNIT_TEST(testUpdateRefCount);
CPPUNIT_TEST(testBasicAddExpire);
CPPUNIT_TEST(testMultipleSources);
CPPUNIT_TEST(testUpdateSequence);
CPPUNIT_TEST(testCallbacks);
CPPUNIT_TEST(testFilters);
CPPUNIT_TEST(testSyncStatus);
CPPUNIT_TEST(testGetWhileSynced);
CPPUNIT_TEST(testGetWhileNotSynced);
CPPUNIT_TEST(testGetEmptySynced);
CPPUNIT_TEST(testValueExpirationDuringListen);
CPPUNIT_TEST(testTimestampOrdering);
CPPUNIT_TEST(testShortExpirationPhantomExpiry);
CPPUNIT_TEST(testHighChurnRefCountConsistency);
CPPUNIT_TEST(testValueUpdateSingleSourcePhantom);
CPPUNIT_TEST_SUITE_END();
public:
void setUp();
void tearDown();
void testUpdateRefCount();
void testBasicAddExpire();
void testMultipleSources();
void testUpdateSequence();
void testCallbacks();
void testFilters();
void testSyncStatus();
void testGetWhileSynced();
void testGetWhileNotSynced();
void testGetEmptySynced();
void testValueExpirationDuringListen();
void testTimestampOrdering();
void testShortExpirationPhantomExpiry();
void testHighChurnRefCountConsistency();
void testValueUpdateSingleSourcePhantom();
};
} // namespace test
+533
View File
@@ -0,0 +1,533 @@
/*
* Copyright (C) 2025 Savoir-faire Linux Inc.
*
* Author: GitHub Copilot
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#include "test_valuecache.h"
#include "../src/value_cache.h"
namespace test {
CPPUNIT_TEST_SUITE_REGISTRATION(ValueCacheTester);
void
ValueCacheTester::setUp()
{}
void
ValueCacheTester::tearDown()
{}
void
ValueCacheTester::testUpdate()
{
int addCount = 0;
dht::ValueCache cache([&](const std::vector<std::shared_ptr<dht::Value>>& vals, bool expired) {
if (!expired)
addCount += vals.size();
});
dht::TypeStore types;
auto v1 = std::make_shared<dht::Value>();
v1->id = 1;
v1->seq = 1;
v1->data = {'A'};
auto v2 = std::make_shared<dht::Value>();
v2->id = 1;
v2->seq = 2;
v2->data = {'B'};
auto now = std::chrono::steady_clock::now();
// Add v1
cache.onValues({v1}, {}, {}, types, now);
CPPUNIT_ASSERT_EQUAL(1, addCount);
// Update v1 -> v2
cache.onValues({v2}, {}, {}, types, now);
CPPUNIT_ASSERT_EQUAL_MESSAGE("Callback should be called for update", 2, addCount);
}
void
ValueCacheTester::testUpdateExpiration()
{
int addCount = 0;
int expireCount = 0;
dht::ValueCache cache([&](const std::vector<std::shared_ptr<dht::Value>>& vals, bool expired) {
if (expired)
expireCount += vals.size();
else
addCount += vals.size();
});
dht::TypeStore types;
auto v1 = std::make_shared<dht::Value>();
v1->id = 1;
v1->seq = 1;
v1->data = {'A'};
auto v2 = std::make_shared<dht::Value>();
v2->id = 1;
v2->seq = 2;
v2->data = {'B'};
auto now = std::chrono::steady_clock::now();
// Add v1
cache.onValues({v1}, {}, {}, types, now);
CPPUNIT_ASSERT_EQUAL(1, addCount);
CPPUNIT_ASSERT_EQUAL(0, expireCount);
// Update v1 -> v2: only the new value is emitted as an add,
// no expire callback for the old value.
cache.onValues({v2}, {}, {}, types, now);
CPPUNIT_ASSERT_EQUAL(2, addCount);
CPPUNIT_ASSERT_EQUAL_MESSAGE("No expire callback on edit", 0, expireCount);
}
void
ValueCacheTester::testExpiration()
{
int expireCount = 0;
dht::ValueCache cache([&](const std::vector<std::shared_ptr<dht::Value>>& vals, bool expired) {
if (expired)
expireCount += vals.size();
});
dht::TypeStore types;
auto v1 = std::make_shared<dht::Value>();
v1->id = 10;
v1->data = {'A'};
// Default expiration is 10 minutes
auto now = std::chrono::steady_clock::now();
cache.onValues({v1}, {}, {}, types, now);
// Advance time past expiration
auto future = now + std::chrono::minutes(11);
// expireValues returns next expiration time
cache.expireValues(future);
CPPUNIT_ASSERT_EQUAL(1, expireCount);
CPPUNIT_ASSERT_EQUAL((size_t) 0, cache.size());
}
void
ValueCacheTester::testRefresh()
{
int expireCount = 0;
dht::ValueCache cache([&](const std::vector<std::shared_ptr<dht::Value>>& vals, bool expired) {
if (expired)
expireCount += vals.size();
});
dht::TypeStore types;
auto v1 = std::make_shared<dht::Value>();
v1->id = 20;
v1->data = {'B'};
auto now = std::chrono::steady_clock::now();
cache.onValues({v1}, {}, {}, types, now);
// Refresh at +5 mins
auto later = now + std::chrono::minutes(5);
cache.onValues({}, {v1->id}, {}, types, later);
// Should not expire at +11 mins (original expiration was +10, so +10 from start)
// But refresh should extend it to +10 from 'later' = +15 from start.
auto checkTime = now + std::chrono::minutes(11);
cache.expireValues(checkTime);
CPPUNIT_ASSERT_EQUAL(0, expireCount);
CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size());
// Should expire at +16 mins
auto future = now + std::chrono::minutes(16);
cache.expireValues(future);
CPPUNIT_ASSERT_EQUAL(1, expireCount);
}
void
ValueCacheTester::testExplicitExpiration()
{
int expireCount = 0;
dht::ValueCache cache([&](const std::vector<std::shared_ptr<dht::Value>>& vals, bool expired) {
if (expired)
expireCount += vals.size();
});
dht::TypeStore types;
auto v1 = std::make_shared<dht::Value>();
v1->id = 30;
v1->data = {'C'};
auto now = std::chrono::steady_clock::now();
cache.onValues({v1}, {}, {}, types, now);
cache.onValues({}, {}, {v1->id}, types, now);
CPPUNIT_ASSERT_EQUAL(1, expireCount);
CPPUNIT_ASSERT_EQUAL((size_t) 0, cache.size());
}
void
ValueCacheTester::testClear()
{
dht::ValueCache cache([](const std::vector<std::shared_ptr<dht::Value>>&, bool) {});
dht::TypeStore types;
auto now = std::chrono::steady_clock::now();
auto v1 = std::make_shared<dht::Value>();
v1->id = 1;
auto v2 = std::make_shared<dht::Value>();
v2->id = 2;
cache.onValues({v1, v2}, {}, {}, types, now);
CPPUNIT_ASSERT_EQUAL((size_t) 2, cache.size());
auto cbs = cache.clear();
// Execute callbacks
for (auto& cb : cbs)
cb();
CPPUNIT_ASSERT_EQUAL((size_t) 0, cache.size());
}
void
ValueCacheTester::testSyncStatus()
{
std::vector<dht::ListenSyncStatus> statuses;
{
dht::ValueCache cache([](const std::vector<std::shared_ptr<dht::Value>>&, bool) {},
[&](dht::ListenSyncStatus status) { statuses.push_back(status); });
CPPUNIT_ASSERT_EQUAL((size_t) 1, statuses.size());
CPPUNIT_ASSERT(statuses.back() == dht::ListenSyncStatus::ADDED);
cache.onSynced(true);
CPPUNIT_ASSERT_EQUAL((size_t) 2, statuses.size());
CPPUNIT_ASSERT(statuses.back() == dht::ListenSyncStatus::SYNCED);
// Calling onSynced(true) again should not trigger the callback
cache.onSynced(true);
CPPUNIT_ASSERT_EQUAL((size_t) 2, statuses.size());
cache.onSynced(false);
CPPUNIT_ASSERT_EQUAL((size_t) 3, statuses.size());
CPPUNIT_ASSERT(statuses.back() == dht::ListenSyncStatus::UNSYNCED);
// Calling onSynced(false) again should not trigger the callback
cache.onSynced(false);
CPPUNIT_ASSERT_EQUAL((size_t) 3, statuses.size());
// Set to synced before destruction to test UNSYNCED on destruction
cache.onSynced(true);
CPPUNIT_ASSERT_EQUAL((size_t) 4, statuses.size());
CPPUNIT_ASSERT(statuses.back() == dht::ListenSyncStatus::SYNCED);
}
// Destructor should have been called
CPPUNIT_ASSERT_EQUAL((size_t) 6, statuses.size());
CPPUNIT_ASSERT(statuses[4] == dht::ListenSyncStatus::UNSYNCED);
CPPUNIT_ASSERT(statuses[5] == dht::ListenSyncStatus::REMOVED);
}
void
ValueCacheTester::testMaxValues()
{
dht::ValueCache cache([](const std::vector<std::shared_ptr<dht::Value>>&, bool) {});
dht::TypeStore types;
auto now = std::chrono::steady_clock::now();
// MAX_VALUES is 4096
for (int i = 0; i < 4100; ++i) {
auto v = std::make_shared<dht::Value>();
v->id = (dht::Value::Id) i + 1;
cache.onValues({v}, {}, {}, types, now + std::chrono::milliseconds(i));
}
// Should be capped at 4096
CPPUNIT_ASSERT_EQUAL((size_t) 4096, cache.size());
}
void
ValueCacheTester::testUpdateTypeExpiration()
{
// Test that when a value is updated with a different type, the expiration
// uses the new type's expiration period, not the old one.
int addCount = 0;
int expireCount = 0;
dht::ValueCache cache([&](const std::vector<std::shared_ptr<dht::Value>>& vals, bool expired) {
if (expired)
expireCount += vals.size();
else
addCount += vals.size();
});
// Register a custom type with a short expiration (2 minutes)
dht::TypeStore types;
dht::ValueType shortType(42, "short", std::chrono::minutes(2));
types.registerType(shortType);
// Register a custom type with a long expiration (30 minutes)
dht::ValueType longType(99, "long", std::chrono::minutes(30));
types.registerType(longType);
auto v1 = std::make_shared<dht::Value>();
v1->id = 1;
v1->type = 42; // short type: 2 min expiration
v1->data = {'A'};
auto now = std::chrono::steady_clock::now();
// Add v1 with short type
cache.onValues({v1}, {}, {}, types, now);
CPPUNIT_ASSERT_EQUAL(1, addCount);
// Update with new type (long expiration)
auto v2 = std::make_shared<dht::Value>();
v2->id = 1;
v2->type = 99; // long type: 30 min expiration
v2->data = {'B'};
cache.onValues({v2}, {}, {}, types, now);
CPPUNIT_ASSERT_EQUAL(2, addCount);
// At +3 minutes: should NOT be expired (long type has 30 min expiration)
// Before the fix, the old type (2 min) would be used, causing expiration here
expireCount = 0;
auto at3min = now + std::chrono::minutes(3);
cache.expireValues(at3min);
CPPUNIT_ASSERT_EQUAL_MESSAGE("Value updated to long-expiry type should NOT expire at 3 min", 0, expireCount);
CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size());
// At +31 minutes: should be expired
auto at31min = now + std::chrono::minutes(31);
cache.expireValues(at31min);
CPPUNIT_ASSERT_EQUAL(1, expireCount);
CPPUNIT_ASSERT_EQUAL((size_t) 0, cache.size());
}
void
ValueCacheTester::testShortExpirationRefreshLost()
{
// Demonstrates that a value with a short expiration type survives
// past its nominal expiration when the listen is synced, thanks to
// a proportional grace period (= typeExpiration).
//
// For a 5s type: effective expiration when synced = expiration + 5s = 10s
//
// Scenario:
// t=0 : Value V added → nominal expiration t+5s, effective t+10s
// t=6 : Past nominal but within grace → alive
// t=8 : Refresh arrives → V refreshed → expiration reset to t8+5s, effective t8+10s=t18s
// t=14: Still alive (within refreshed effective range)
// t=20: No more refreshes → past t8+10s=t18s → expired
int addCount = 0;
int expireCount = 0;
dht::ValueCache cache([&](const std::vector<std::shared_ptr<dht::Value>>& vals, bool expired) {
if (expired)
expireCount += vals.size();
else
addCount += vals.size();
});
// Register a type with a very short expiration (5 seconds)
dht::TypeStore types;
dht::ValueType shortType(100, "short_lived", std::chrono::seconds(5));
types.registerType(shortType);
auto v1 = std::make_shared<dht::Value>();
v1->id = 1;
v1->type = 100;
v1->data = {'V'};
auto t0 = std::chrono::steady_clock::now();
// t=0: Listen response arrives with V
cache.onValues({v1}, {}, {}, types, t0);
CPPUNIT_ASSERT_EQUAL(1, addCount);
CPPUNIT_ASSERT_EQUAL(0, expireCount);
CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size());
// Mark cache as synced (listen is active)
cache.onSynced(true);
// t=6: Past nominal 5s expiration, but grace (5s) gives effective 10s
auto t6 = t0 + std::chrono::seconds(6);
cache.expireValues(t6);
CPPUNIT_ASSERT_EQUAL_MESSAGE("Value should survive past nominal expiration thanks to proportional grace",
0,
expireCount);
CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size());
// t=8: Value refresh arrives → expiration reset to t8+5s=t13s, effective t8+10s=t18s
auto t8 = t0 + std::chrono::seconds(8);
cache.onValues({}, {v1->id}, {}, types, t8);
CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size());
// t=14: Within refreshed effective range (t18s) → alive
auto t14 = t0 + std::chrono::seconds(14);
cache.expireValues(t14);
CPPUNIT_ASSERT_EQUAL_MESSAGE("Value should survive past short expiration after refresh", 0, expireCount);
CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size());
// t=20: No more refreshes → past effective t18s → must expire
auto t20 = t0 + std::chrono::seconds(20);
cache.expireValues(t20);
CPPUNIT_ASSERT_EQUAL_MESSAGE("Value must eventually expire even while synced if server stops refreshing",
1,
expireCount);
CPPUNIT_ASSERT_EQUAL((size_t) 0, cache.size());
}
void
ValueCacheTester::testShortExpirationHighChurn()
{
// Simulates high churn: multiple rapid add/expire/refresh cycles with
// short-lived values, verifying the cache stays consistent.
int addCount = 0;
int expireCount = 0;
dht::ValueCache cache([&](const std::vector<std::shared_ptr<dht::Value>>& vals, bool expired) {
if (expired)
expireCount += vals.size();
else
addCount += vals.size();
});
dht::TypeStore types;
dht::ValueType shortType(101, "fast_churn", std::chrono::seconds(3));
types.registerType(shortType);
auto now = std::chrono::steady_clock::now();
// Simulate 10 churn cycles: add value, advance time, refresh, advance, expire
for (int cycle = 0; cycle < 10; cycle++) {
auto v = std::make_shared<dht::Value>();
v->id = static_cast<dht::Value::Id>(500 + cycle);
v->type = 101;
v->data = {static_cast<uint8_t>('A' + cycle)};
auto t = now + std::chrono::seconds(cycle * 10);
// Add the value
cache.onValues({v}, {}, {}, types, t);
// Refresh halfway through
auto tRefresh = t + std::chrono::seconds(2);
cache.onValues({}, {v->id}, {}, types, tRefresh);
// Explicit expire from server
auto tExpire = t + std::chrono::seconds(8);
cache.onValues({}, {}, {v->id}, types, tExpire);
}
// All 10 values were added and explicitly expired
CPPUNIT_ASSERT_EQUAL(10, addCount);
CPPUNIT_ASSERT_EQUAL(10, expireCount);
CPPUNIT_ASSERT_EQUAL((size_t) 0, cache.size());
}
void
ValueCacheTester::testNoTimeExpirationWhileSynced()
{
// Verify that when the listen is synced, the proportional grace period
// (= typeExpiration) delays time-based expiration but doesn't prevent
// it entirely. For a 2s type: effective expiration = 2s + 2s = 4s.
int expireCount = 0;
dht::ValueCache cache([&](const std::vector<std::shared_ptr<dht::Value>>& vals, bool expired) {
if (expired)
expireCount += vals.size();
});
dht::TypeStore types;
dht::ValueType shortType(110, "short", std::chrono::seconds(2));
types.registerType(shortType);
auto v1 = std::make_shared<dht::Value>();
v1->id = 1;
v1->type = 110;
v1->data = {'X'};
auto t0 = std::chrono::steady_clock::now();
cache.onValues({v1}, {}, {}, types, t0);
CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size());
// Mark as synced
cache.onSynced(true);
// t=3: Past nominal 2s, within effective 4s → grace protects
auto t3 = t0 + std::chrono::seconds(3);
cache.expireValues(t3);
CPPUNIT_ASSERT_EQUAL_MESSAGE("Synced cache: grace period should prevent early expiry", 0, expireCount);
CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size());
// t=5: Past effective 4s (2s + 2s grace) → must expire
auto t5 = t0 + std::chrono::seconds(5);
cache.expireValues(t5);
CPPUNIT_ASSERT_EQUAL_MESSAGE("Synced cache: value must eventually expire after grace period", 1, expireCount);
CPPUNIT_ASSERT_EQUAL((size_t) 0, cache.size());
}
void
ValueCacheTester::testUnsyncExpiresImmediately()
{
// When the cache goes from synced to unsynced, the grace period is
// removed and already-past-due values expire on the next check.
// Type expiration = 5s. Grace when synced = 5s → effective = 10s.
int expireCount = 0;
dht::ValueCache cache([&](const std::vector<std::shared_ptr<dht::Value>>& vals, bool expired) {
if (expired)
expireCount += vals.size();
});
dht::TypeStore types;
dht::ValueType shortType(120, "short", std::chrono::seconds(5));
types.registerType(shortType);
auto v1 = std::make_shared<dht::Value>();
v1->id = 1;
v1->type = 120;
v1->data = {'U'};
auto t0 = std::chrono::steady_clock::now();
cache.onValues({v1}, {}, {}, types, t0);
cache.onSynced(true);
// t=7: past nominal 5s, within effective 10s while synced → alive
auto t7 = t0 + std::chrono::seconds(7);
cache.expireValues(t7);
CPPUNIT_ASSERT_EQUAL(0, expireCount);
// Now unsync (node went away / connectivity loss)
cache.onSynced(false);
// Same time point t=7: past nominal 5s, no grace → expires
cache.expireValues(t7);
CPPUNIT_ASSERT_EQUAL_MESSAGE("Unsynced cache should immediately expire past-due values", 1, expireCount);
CPPUNIT_ASSERT_EQUAL((size_t) 0, cache.size());
}
} // namespace test
+64
View File
@@ -0,0 +1,64 @@
/*
* Copyright (C) 2025 Savoir-faire Linux Inc.
*
* Author: GitHub Copilot
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <cppunit/TestFixture.h>
#include <cppunit/extensions/HelperMacros.h>
namespace test {
class ValueCacheTester : public CppUnit::TestFixture
{
CPPUNIT_TEST_SUITE(ValueCacheTester);
CPPUNIT_TEST(testUpdate);
CPPUNIT_TEST(testUpdateExpiration);
CPPUNIT_TEST(testExpiration);
CPPUNIT_TEST(testRefresh);
CPPUNIT_TEST(testExplicitExpiration);
CPPUNIT_TEST(testClear);
CPPUNIT_TEST(testSyncStatus);
CPPUNIT_TEST(testMaxValues);
CPPUNIT_TEST(testUpdateTypeExpiration);
CPPUNIT_TEST(testShortExpirationRefreshLost);
CPPUNIT_TEST(testShortExpirationHighChurn);
CPPUNIT_TEST(testNoTimeExpirationWhileSynced);
CPPUNIT_TEST(testUnsyncExpiresImmediately);
CPPUNIT_TEST_SUITE_END();
public:
void setUp();
void tearDown();
void testUpdate();
void testUpdateExpiration();
void testExpiration();
void testRefresh();
void testExplicitExpiration();
void testClear();
void testSyncStatus();
void testMaxValues();
void testUpdateTypeExpiration();
void testShortExpirationRefreshLost();
void testShortExpirationHighChurn();
void testNoTimeExpirationWhileSynced();
void testUnsyncExpiresImmediately();
};
} // namespace test